From 6bf647468f8e1674354eb7d509f1dc1728c1f29c Mon Sep 17 00:00:00 2001 From: Jayesh Deorukhkar Date: Fri, 10 Jan 2025 15:40:21 +0530 Subject: [PATCH 01/66] fix: convert numeric width to string for images --- src/toRedactor.tsx | 3 +++ test/toRedactor.test.ts | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index 69670e9..4519914 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -379,6 +379,9 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string } if (attrsJson['width']) { let width = attrsJson['width'] + if(typeof width === 'number'){ + width = width.toString() + } if (width.slice(width.length - 1) === '%') { style = `width: ${allattrs['width'] + '%'}; height: ${attrsJson['height'] ? attrsJson['height'] : 'auto'};` } else { diff --git a/test/toRedactor.test.ts b/test/toRedactor.test.ts index ca5e21f..f5d6b49 100644 --- a/test/toRedactor.test.ts +++ b/test/toRedactor.test.ts @@ -268,5 +268,11 @@ describe("Testing json to html conversion", () => { expect(html).toBe(expectedValue["RT-360"].html[2]); }) }) + + test('should convert numeric width to string', () => { + const json = {"type":"doc","uid":"0ebe9a3b835d413595885c44d9527b72","attrs":{},"children":[{"type":"img","attrs":{"style":{"text-align":"center"},"redactor-attributes":{"alt":"Infographic showing 3 results from Forrester study of Contentstack CMS: $3M increase in profit, $507.3K productivity savings and $2.0M savings due to reduced time to publish.","src":"https://images.contentstack.io/v3/assets/blt7359e2a55efae483/bltea2a11144a2c68b5/63c08b7f438f80612c397994/CS_Infographics_ForresterReport_Data_3_1200x628_(1).png","position":"center","width":641},"url":"https://images.contentstack.io/v3/assets/blt7359e2a55efae483/bltea2a11144a2c68b5/63c08b7f438f80612c397994/CS_Infographics_ForresterReport_Data_3_1200x628_(1).png","width":641},"uid":"15516d511e7a4e28b418e49bdba0464d","children":[{"text":""}]}] } + const html = toRedactor(json); + expect(html).toBe(`Infographic showing 3 results from Forrester study of Contentstack CMS: $3M increase in profit, $507.3K productivity savings and $2.0M savings due to reduced time to publish.`) + }) }) From 10c3a0ff125194e72195254752646ed7a482983a Mon Sep 17 00:00:00 2001 From: Jayesh Deorukhkar <52153715+Jayesh2812@users.noreply.github.com> Date: Fri, 10 Jan 2025 15:51:47 +0530 Subject: [PATCH 02/66] Update package.json version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index be2705c..717fc0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/json-rte-serializer", - "version": "2.0.12", + "version": "2.1.0", "description": "This Package converts Html Document to Json and vice-versa.", "main": "lib/index.js", "module": "lib/index.mjs", From 50dff967cccce2b55bebc6d0046cef8891866ff5 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Thu, 16 Jan 2025 14:44:49 +0530 Subject: [PATCH 03/66] fix: ecode social-embeds and embeds src urls --- README.md | 56 ++--------------------------------------- package.json | 2 +- src/toRedactor.tsx | 14 +++-------- src/types.ts | 1 - test/expectedJson.ts | 49 +++++++++++++++++++++++++++++++++--- test/toRedactor.test.ts | 10 ++++++-- 6 files changed, 59 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 5d3005b..2d2625e 100644 --- a/README.md +++ b/README.md @@ -231,60 +231,6 @@ The resulting HTML data will look as follows: ```HTML

This is text.

``` -
-
- -#####

You can pass the option `skipURLSanitization` as true to bypass the validation checks and sanitization for the src URLs of JSON element types - social embed and embed.

-
By default, this option is set to false.
- -#### Examples: - - 1. For the following JSON, with src url containing script tags - ```JSON - { - "type": "doc", - "attrs": {}, - "children": [ - { - "type": "social-embeds", - "attrs": { - "src": "https://www.youtube.com/watch?v=Gw7EqoOYC9A\"> -``` - -2. For any JSON containing src urls violating expected protocols, the src attribute will be removed when converted to HTML - - ```JSON - { - "type": "doc", - "attrs": {}, - "children": [ - { - "type": "social-embeds", - "attrs": { - "src": "www.youtube.com/watch?v=Gw7EqoOYC9A\">", - "width": 560, - "height": 320 - }, - } - ] - } -``` -The resulting HTML: -```HTML - -``` - -
### Convert HTML to JSON @@ -411,6 +357,8 @@ The resulting JSON-formatted data will look as follows: ## Automatic Conversion +> **_Note_**: `src` url's provided for social-embeds and embed items will by default be encoded. + By default, the JSON Rich Text Editor field supports limited HTML tags within the editor. Due to this, the JSON RTE Serializer tool is not able to recognize each and every standard HTML tag. To help the JSON RTE Serializer recognize and process additional tags that are commonly used across HTML, you can use the automatic conversion option. When using this option, you need to pass the `allowNonStandardTags: true` parameter within the `jsonToHtml` or `htmlToJson` method to manipulate the working of the JSON RTE Serializer package as per your requirements. When you pass this parameter, it customizes your JSON RTE Serializer code to allow the support for all standard HTML-recognized tags or element types in the JSON Rich Text Editor field. diff --git a/package.json b/package.json index 717fc0e..53cd1a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/json-rte-serializer", - "version": "2.1.0", + "version": "2.0.13", "description": "This Package converts Html Document to Json and vice-versa.", "main": "lib/index.js", "module": "lib/index.mjs", diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index 4519914..b7b1c8e 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -498,17 +498,9 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string figureStyles.fieldsEdited.push(figureStyles.caption) } - if (!options?.skipURLSanitization && (jsonValue['type'] === 'social-embeds' || jsonValue['type'] === 'embed')) { - const sanitizedHTML = DOMPurify.sanitize(allattrs['src']); - - const urlMatch = sanitizedHTML.match(/https?:\/\/[^\s"'<>()]+/); - - if (urlMatch) { - attrsJson['src'] = decodeURIComponent(urlMatch[0]); - } else { - delete attrsJson['src']; - } - } + if (jsonValue['type'] === 'social-embeds' || jsonValue['type'] === 'embed') { + attrsJson['src'] = encodeURI(allattrs['src']); + } if(!(options?.customElementTypes && !isEmpty(options.customElementTypes) && options.customElementTypes[jsonValue['type']])) { delete attrsJson['url'] diff --git a/src/types.ts b/src/types.ts index c531d28..adb3785 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,5 +20,4 @@ export interface IJsonToHtmlOptions { customElementTypes?: IJsonToHtmlElementTags, customTextWrapper?: IJsonToHtmlTextTags, allowNonStandardTypes?: boolean, - skipURLSanitization?:boolean } diff --git a/test/expectedJson.ts b/test/expectedJson.ts index 8202d8b..fa21d5a 100644 --- a/test/expectedJson.ts +++ b/test/expectedJson.ts @@ -2002,9 +2002,10 @@ export default { }, "RT-360":{ "html": [ - ``, + ``, ``, - '', + '', + `` ], "json": [ @@ -2057,7 +2058,7 @@ export default { "uid": "45a850acbeb949db86afe415625ad1ce", "type": "social-embeds", "attrs": { - "src": null, + "src": "", "width": 560, "height": 320 }, @@ -2077,7 +2078,7 @@ export default { "uid": "87fed1cc68ce435caa0f71d17788c618", "type": "embed", "attrs": { - "src": null, + "src": "", "redactor-attributes": { "allowfullscreen": true } @@ -2127,6 +2128,46 @@ export default { } ], "_version": 1 + }, + { + "type": "doc", + "attrs": {}, + "uid": "18396bf67f1f4b0a9da57643ac0542ca", + "children": [ + { + "uid": "45a850acbeb949db86afe415625ad1ce", + "type": "social-embeds", + "attrs": { + "src": "https://www.youtube.com/embed/Gw7EqoOYC9A?si=bWdnezma6qFAePQU", + "width": 560, + "height": 320 + }, + "children": [ + { + "text": "" + } + ] + }, + { + "uid": "d3c2ab78a5e547b082f95dc01123b0c1", + "type": "doc", + "_version": 11, + "attrs": {}, + "children": [ + { + "uid": "87fed1cc68ce435caa0f71d17788c618", + "type": "embed", + "attrs": { + "src": "https://www.youtube.com/embed/Gw7EqoOYC9A?si=bWdnezma6qFAePQU", + "redactor-attributes": { + "allowfullscreen": true + } + } + } + ] + } + ], + "_version": 1 } ] diff --git a/test/toRedactor.test.ts b/test/toRedactor.test.ts index f5d6b49..87e5471 100644 --- a/test/toRedactor.test.ts +++ b/test/toRedactor.test.ts @@ -250,7 +250,7 @@ describe("Testing json to html conversion", () => { }) describe("RT-360", () =>{ - it("should remove script and/or other tags from src links in HTML for social-embeds", () => { + it("should encode and not render invalid src urls", () => { const json = expectedValue["RT-360"].json[0] const html = toRedactor(json); expect(html).toBe(expectedValue["RT-360"].html[0]); @@ -262,11 +262,17 @@ describe("Testing json to html conversion", () => { expect(html).toBe(expectedValue["RT-360"].html[1]); }) - it("should handle src without protocol",()=>{ + it("should handle src urls without protocol",()=>{ const json = expectedValue["RT-360"].json[2] const html = toRedactor(json); expect(html).toBe(expectedValue["RT-360"].html[2]); }) + + it("should work only for valid embed urls",()=>{ + const json = expectedValue["RT-360"].json[3] + const html = toRedactor(json); + expect(html).toBe(expectedValue["RT-360"].html[3]); + }) }) test('should convert numeric width to string', () => { From 9daa205b4cf8671e76410a4dfe28f4481cc32ce3 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Thu, 16 Jan 2025 15:08:30 +0530 Subject: [PATCH 04/66] chore: readme update --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2d2625e..1d9519a 100644 --- a/README.md +++ b/README.md @@ -357,7 +357,7 @@ The resulting JSON-formatted data will look as follows: ## Automatic Conversion -> **_Note_**: `src` url's provided for social-embeds and embed items will by default be encoded. +> **_Note_**: `src` url's provided for social-embeds and embed items will by default be uri encoded. By default, the JSON Rich Text Editor field supports limited HTML tags within the editor. Due to this, the JSON RTE Serializer tool is not able to recognize each and every standard HTML tag. From e46ee03851e5d1b6a34c4498f7c6635d86b2af6f Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Tue, 21 Jan 2025 17:21:12 +0530 Subject: [PATCH 05/66] fix: escape html entities in attr values --- package-lock.json | 33 ++------------------------------- package.json | 1 - src/toRedactor.tsx | 6 +++--- src/utils/index.ts | 7 +++++++ test/expectedJson.ts | 17 +++++++++++++++-- test/toRedactor.test.ts | 6 ++++++ 6 files changed, 33 insertions(+), 37 deletions(-) create mode 100644 src/utils/index.ts diff --git a/package-lock.json b/package-lock.json index 1ee91c5..7deee9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,15 @@ { "name": "@contentstack/json-rte-serializer", - "version": "2.0.12", + "version": "2.0.13", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@contentstack/json-rte-serializer", - "version": "2.0.12", + "version": "2.0.13", "license": "MIT", "dependencies": { "array-flat-polyfill": "^1.0.1", - "dompurify": "^3.2.3", "lodash": "^4.17.21", "lodash.clonedeep": "^4.5.0", "lodash.flatten": "^4.4.0", @@ -1609,12 +1608,6 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "dev": true }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "optional": true - }, "node_modules/@types/uuid": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", @@ -2250,14 +2243,6 @@ "node": ">=8" } }, - "node_modules/dompurify": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.3.tgz", - "integrity": "sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/electron-to-chromium": { "version": "1.4.622", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.622.tgz", @@ -6280,12 +6265,6 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "dev": true }, - "@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "optional": true - }, "@types/uuid": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", @@ -6756,14 +6735,6 @@ } } }, - "dompurify": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.3.tgz", - "integrity": "sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==", - "requires": { - "@types/trusted-types": "^2.0.7" - } - }, "electron-to-chromium": { "version": "1.4.622", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.622.tgz", diff --git a/package.json b/package.json index 53cd1a6..626ec39 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ }, "dependencies": { "array-flat-polyfill": "^1.0.1", - "dompurify": "^3.2.3", "lodash": "^4.17.21", "lodash.clonedeep": "^4.5.0", "lodash.flatten": "^4.4.0", diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index b7b1c8e..2dc1406 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -1,8 +1,8 @@ import kebbab from 'lodash.kebabcase' import isEmpty from 'lodash.isempty' -import DOMPurify from 'dompurify' import {IJsonToHtmlElementTags, IJsonToHtmlOptions, IJsonToHtmlTextTags} from './types' import isPlainObject from 'lodash.isplainobject' +import {replaceHtmlEntities } from './utils' const ELEMENT_TYPES: IJsonToHtmlElementTags = { 'blockquote': (attrs: string, child: string) => { @@ -507,7 +507,7 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string } delete attrsJson['redactor-attributes'] Object.entries(attrsJson).forEach((key) => { - return key[1] ? (key[1] !== '' ? (attrs += `${key[0]}="${key[1]}" `) : '') : '' + return key[1] ? (key[1] !== '' ? (attrs += `${key[0]}="${replaceHtmlEntities(key[1])}" `) : '') : '' }) attrs = (attrs.trim() ? ' ' : '') + attrs.trim() } @@ -564,7 +564,7 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string if(['td','th'].includes(jsonValue['type'])){ if(jsonValue?.['attrs']?.['void']) return '' } - + attrs = (attrs.trim() ? ' ' : '') + attrs.trim() return ELEMENT_TYPES[orgType || jsonValue['type']](attrs, children,jsonValue, figureStyles) diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..b4b9c08 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,7 @@ +export function replaceHtmlEntities(str: string): string { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/test/expectedJson.ts b/test/expectedJson.ts index fa21d5a..1433de4 100644 --- a/test/expectedJson.ts +++ b/test/expectedJson.ts @@ -2005,7 +2005,8 @@ export default { ``, ``, '', - `` + ``, + `` ], "json": [ @@ -2168,7 +2169,19 @@ export default { } ], "_version": 1 - } + }, + { + uid: "45a850acbeb949db86afe415625ad1ce", + type: "social-embeds", + attrs: { + src: "null", + width: 560, + height: 320, + title: " This is for

testing

purpose 'only' " + }, + children: [{ text: "" }], + }, + ] } diff --git a/test/toRedactor.test.ts b/test/toRedactor.test.ts index 87e5471..6517f88 100644 --- a/test/toRedactor.test.ts +++ b/test/toRedactor.test.ts @@ -273,6 +273,12 @@ describe("Testing json to html conversion", () => { const html = toRedactor(json); expect(html).toBe(expectedValue["RT-360"].html[3]); }) + + it("should escape html entities in attribute values",()=>{ + const json = expectedValue["RT-360"].json[4] + const html = toRedactor(json); + expect(html).toBe(expectedValue["RT-360"].html[4]); + }) }) test('should convert numeric width to string', () => { From 16d29e60186665f200fcfe59bc772006c2370dd6 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Wed, 22 Jan 2025 16:52:39 +0530 Subject: [PATCH 06/66] feat: handle invalid attr key-values --- README.md | 29 +++++++++++++++++++++++++++++ package.json | 2 +- src/toRedactor.tsx | 5 ++++- src/utils/index.ts | 2 ++ test/expectedJson.ts | 22 +++++++++++++++++++++- test/toRedactor.test.ts | 6 ++++++ 6 files changed, 63 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1d9519a..726a853 100644 --- a/README.md +++ b/README.md @@ -365,6 +365,35 @@ To help the JSON RTE Serializer recognize and process additional tags that are c ### Convert JSON to HTML +#### HTML Attribute Name and Value Sanitization + + +This project ensures that HTML attributes are properly validated and sanitized according to the W3C HTML specification. It validates attribute names based on the HTML standards and sanitizes attribute values to ensure correct rendering and security, particularly against cross-site scripting (XSS) vulnerabilities. + +#### Attribute Name Guidelines + +All HTML attribute names must conform to the [W3C HTML specification](https://www.w3.org/TR/2012/WD-html-markup-20120329/syntax.html#attribute-name). These guidelines specify the following rules: + +- **Printable ASCII Characters:** Attribute names must consist only of printable ASCII characters. +- **Case-Insensitive:** Attribute names are case-insensitive, but lowercase is preferred for consistency. +- **No Special Characters:** Attribute names cannot contain spaces or special characters such as `=`, `>`, `<`, `"`, etc. +- **Allowed Attributes:** Attributes such as `xmlns`, `aria-*`, `data-*`, and others defined by HTML5 standards are allowed and must follow specific rules. + +##### Important Note: +If an attribute name does not conform to these rules, the attribute will be **dropped** from the element. + +#### Attribute Value Guidelines + +The values of HTML attributes are sanitized to ensure proper rendering and to mitigate security risks, such as Cross-Site Scripting (XSS). This sanitization process involves replacing HTML entities (like `<`, `>`, `&`, etc.) with their corresponding characters and removing any invalid or unsafe characters. + +Here are some common HTML entities and their replacements: + +- `<` → `<` +- `>` → `>` +- `&` → `&` + + +
You can pass the `allowNonStandardTags: true` parameter within the `jsonToHtml` method to allow the JSON RTE Serializer tool to recognize standard HTML tags or element types and convert them into JSON format. You can use the following customized JSON RTE Serializer code to convert your JSON RTE field data into HTML format. diff --git a/package.json b/package.json index 626ec39..c981325 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/json-rte-serializer", - "version": "2.0.13", + "version": "2.1.0", "description": "This Package converts Html Document to Json and vice-versa.", "main": "lib/index.js", "module": "lib/index.mjs", diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index 2dc1406..ca5abde 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -2,7 +2,7 @@ import kebbab from 'lodash.kebabcase' import isEmpty from 'lodash.isempty' import {IJsonToHtmlElementTags, IJsonToHtmlOptions, IJsonToHtmlTextTags} from './types' import isPlainObject from 'lodash.isplainobject' -import {replaceHtmlEntities } from './utils' +import {replaceHtmlEntities, forbiddenAttrChars } from './utils' const ELEMENT_TYPES: IJsonToHtmlElementTags = { 'blockquote': (attrs: string, child: string) => { @@ -507,6 +507,9 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string } delete attrsJson['redactor-attributes'] Object.entries(attrsJson).forEach((key) => { + if (forbiddenAttrChars.some(char => key[0].includes(char))) { + return; + } return key[1] ? (key[1] !== '' ? (attrs += `${key[0]}="${replaceHtmlEntities(key[1])}" `) : '') : '' }) attrs = (attrs.trim() ? ' ' : '') + attrs.trim() diff --git a/src/utils/index.ts b/src/utils/index.ts index b4b9c08..df48a1c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -5,3 +5,5 @@ export function replaceHtmlEntities(str: string): string { .replace(/>/g, '>') .replace(/"/g, '"'); } + +export const forbiddenAttrChars = ['"', "'", '>','<', '/', '=']; \ No newline at end of file diff --git a/test/expectedJson.ts b/test/expectedJson.ts index 1433de4..a77a1d5 100644 --- a/test/expectedJson.ts +++ b/test/expectedJson.ts @@ -2006,7 +2006,8 @@ export default { ``, '', ``, - `` + ``, + `` ], "json": [ @@ -2181,6 +2182,25 @@ export default { }, children: [{ text: "" }], }, + { + "uid": "45a850acbeb949db86afe415625ad1ce", + "type": "social-embeds", + "attrs": { + "123": "456", + "src": "https://www.youtube.com/embed/Gw7EqoOYC9A?si=bWdnezma6qFAePQU", + "width": 560, + "height": 320, + "

ding": 234, + "status": "Active", + "emptyKey<": "12", + "country/": "USA" + }, + "children": [ + { + "text": "" + } + ] + }, ] diff --git a/test/toRedactor.test.ts b/test/toRedactor.test.ts index 6517f88..adbb8aa 100644 --- a/test/toRedactor.test.ts +++ b/test/toRedactor.test.ts @@ -279,6 +279,12 @@ describe("Testing json to html conversion", () => { const html = toRedactor(json); expect(html).toBe(expectedValue["RT-360"].html[4]); }) + + it("should drop invalid attribute names",()=>{ + const json = expectedValue["RT-360"].json[5] + const html = toRedactor(json); + expect(html).toBe(expectedValue["RT-360"].html[5]); + }) }) test('should convert numeric width to string', () => { From f4a66c2282e5b4a514c282733d2124bb2b9cdc38 Mon Sep 17 00:00:00 2001 From: shreya-kamble <92981124+shreya-kamble@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:57:48 +0530 Subject: [PATCH 07/66] Update LICENSE --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 46147ae..60c49c4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023-2024 Contentstack +Copyright (c) 2024-2025 Contentstack Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 4264c10da3030a87c1185301886da47465aa2f44 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Thu, 6 Mar 2025 12:28:27 +0530 Subject: [PATCH 08/66] fix: retain empty strings value for alt attr for img and asset while conversion to html --- src/toRedactor.tsx | 10 +++++++--- test/expectedJson.ts | 30 ++++++++++++++++++++++++++++++ test/toRedactor.test.ts | 7 +++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index ca5abde..1b8c7b5 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -506,11 +506,15 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string delete attrsJson['url'] } delete attrsJson['redactor-attributes'] - Object.entries(attrsJson).forEach((key) => { - if (forbiddenAttrChars.some(char => key[0].includes(char))) { + Object.entries(attrsJson).forEach((item) => { + if (forbiddenAttrChars.some(char => item[0].includes(char))) { return; } - return key[1] ? (key[1] !== '' ? (attrs += `${key[0]}="${replaceHtmlEntities(key[1])}" `) : '') : '' + if((jsonValue['type'] === 'img' || (jsonValue['type'] === 'reference') && jsonValue.attrs['display-type'] === 'display' ) && item[0] === 'alt'){ + attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" ` + return; + } + return item[1] ? (item[1] !== '' ? (attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" `) : '') : '' }) attrs = (attrs.trim() ? ' ' : '') + attrs.trim() } diff --git a/test/expectedJson.ts b/test/expectedJson.ts index a77a1d5..0c79455 100644 --- a/test/expectedJson.ts +++ b/test/expectedJson.ts @@ -2204,6 +2204,36 @@ export default { ] + }, + "RT-268":{ + "html": ``, + "json": + { + "id": "a4794fb7214745a2a47fc24104b762f9", + "type": "docs", + "children": [ + { + "type": "img", + "attrs": { + "url": "image_url.jpeg", + "redactor-attributes": { + "alt": "", + "src": "image_url.jpeg", + "width": "100" + }, + "width": "100" + }, + "uid": "18ff239605014dcaaa23c705caf99403", + "children": [ + { + "text": "" + } + ] + } + ] + } + + } } \ No newline at end of file diff --git a/test/toRedactor.test.ts b/test/toRedactor.test.ts index adbb8aa..4801710 100644 --- a/test/toRedactor.test.ts +++ b/test/toRedactor.test.ts @@ -3,6 +3,7 @@ import isEqual from "lodash.isequal" import expectedValue from "./expectedJson" import { imageAssetData } from "./testingData" +import exp from "constants" describe("Testing json to html conversion", () => { it("heading conversion", () => { @@ -292,5 +293,11 @@ describe("Testing json to html conversion", () => { const html = toRedactor(json); expect(html).toBe(`Infographic showing 3 results from Forrester study of Contentstack CMS: $3M increase in profit, $507.3K productivity savings and $2.0M savings due to reduced time to publish.`) }) + + test(' should retain empty string value for alt attribute', () => { + const json = expectedValue['RT-268'].json; + const html = toRedactor(json); + expect(html).toBe(expectedValue['RT-268'].html); + }) }) From a9c37565954f64b41b1473118ccdc59180b5f262 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Thu, 6 Mar 2025 12:43:20 +0530 Subject: [PATCH 09/66] chore: remove unnecessary imports --- test/toRedactor.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/toRedactor.test.ts b/test/toRedactor.test.ts index 4801710..6246b3e 100644 --- a/test/toRedactor.test.ts +++ b/test/toRedactor.test.ts @@ -1,9 +1,7 @@ import { toRedactor } from "../src/toRedactor" -import isEqual from "lodash.isequal" - import expectedValue from "./expectedJson" import { imageAssetData } from "./testingData" -import exp from "constants" + describe("Testing json to html conversion", () => { it("heading conversion", () => { From 5eb814c7a1f51d1a5127c1d2b375d9b86d4957f8 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Mon, 10 Mar 2025 17:43:21 +0530 Subject: [PATCH 10/66] feat: dynamically allow attribute values to be empty if elemnt and attribute is passed throug as options --- README.md | 10 ++++ src/toRedactor.tsx | 35 ++++++++++-- src/types.ts | 2 + test/expectedJson.ts | 120 ++++++++++++++++++++++++++++++++++++++-- test/toRedactor.test.ts | 27 +++++++-- 5 files changed, 181 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 726a853..55df54b 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,12 @@ On the other hand, the `customTextWrapper` parser function provides the followin - `value`: The value passed against the child element +You can pass an object to `allowedEmptyAttributes` to retain empty attribute values for specific element types during HTML conversion. + +**Note:** +By default, if nothing is passed to `allowedEmptyAttributes`, we retain the `alt` attribute for `` and `reference` (asset) element types, even when its value is empty, during HTML conversion. + + You can use the following customized JSON RTE Serializer code to convert your JSON RTE field data into HTML format. ```javascript @@ -216,6 +222,10 @@ const htmlValue = jsonToHtml( return `${child}`; }, }, + allowedEmptyAttributes : { + "p": ["dir"], + "img" : ["width"] + } } ); diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index 1b8c7b5..6f3cf9f 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -1,6 +1,6 @@ import kebbab from 'lodash.kebabcase' import isEmpty from 'lodash.isempty' -import {IJsonToHtmlElementTags, IJsonToHtmlOptions, IJsonToHtmlTextTags} from './types' +import {IJsonToHtmlElementTags, IJsonToHtmlOptions, IJsonToHtmlTextTags, IJsonToHtmlAllowedEmptyAttributes} from './types' import isPlainObject from 'lodash.isplainobject' import {replaceHtmlEntities, forbiddenAttrChars } from './utils' @@ -213,11 +213,28 @@ const TEXT_WRAPPERS: IJsonToHtmlTextTags = { return `${child}` }, } +const ALLOWED_EMPTY_ATTRIBUTES: IJsonToHtmlAllowedEmptyAttributes = { + img: ['alt'], + reference: ['alt'] +} + export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string => { //TODO: optimize assign once per function call if(options?.customTextWrapper && !isEmpty(options.customTextWrapper)){ Object.assign(TEXT_WRAPPERS,options.customTextWrapper) } + if (options?.allowedEmptyAttributes && !isEmpty(options.allowedEmptyAttributes)) { + Object.keys(options.allowedEmptyAttributes).forEach(key => { + if (key === 'img' || key === 'reference') { + ALLOWED_EMPTY_ATTRIBUTES[key] = [ + 'alt', + ...(options.allowedEmptyAttributes?.[key] || []) + ]; + } else { + ALLOWED_EMPTY_ATTRIBUTES[key] = options.allowedEmptyAttributes?.[key] ?? []; + } + }); + } if (jsonValue.hasOwnProperty('text')) { let text = jsonValue['text'].replace(//g, '>') if (jsonValue['break']) { @@ -510,12 +527,20 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string if (forbiddenAttrChars.some(char => item[0].includes(char))) { return; } - if((jsonValue['type'] === 'img' || (jsonValue['type'] === 'reference') && jsonValue.attrs['display-type'] === 'display' ) && item[0] === 'alt'){ - attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" ` - return; - } + if (ALLOWED_EMPTY_ATTRIBUTES.hasOwnProperty(jsonValue['type'])) { + if (ALLOWED_EMPTY_ATTRIBUTES[jsonValue['type']].includes(item[0])) { + // Check for 'display-type' attribute for reference type, as refernce is used for entries and assets + if (jsonValue['type'] === 'reference' && jsonValue.attrs['display-type'] === 'display') { + attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" `; + return; + } + attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" `; + return; + } + } return item[1] ? (item[1] !== '' ? (attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" `) : '') : '' }) + attrs = (attrs.trim() ? ' ' : '') + attrs.trim() } if (jsonValue['type'] === 'table') { diff --git a/src/types.ts b/src/types.ts index adb3785..e531ecf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,10 +14,12 @@ export interface IHtmlToJsonElementTags { [key: string]: (el:HTMLElement) => IHt export interface IJsonToHtmlTextTags { [key: string]: (child:any, value:any) => string } export interface IJsonToHtmlElementTags { [key: string]: (attrs:string,child:string,jsonBlock:IAnyObject,extraProps?:object) => string } +export interface IJsonToHtmlAllowedEmptyAttributes { [key: string]: string[]; } export interface IJsonToMarkdownElementTags{[key: string]: (attrsJson:IAnyObject,child:string) => string} export interface IJsonToMarkdownTextTags{ [key: string]: (child:any, value:any) => string } export interface IJsonToHtmlOptions { customElementTypes?: IJsonToHtmlElementTags, customTextWrapper?: IJsonToHtmlTextTags, allowNonStandardTypes?: boolean, + allowedEmptyAttributes?: IJsonToHtmlAllowedEmptyAttributes, } diff --git a/test/expectedJson.ts b/test/expectedJson.ts index 0c79455..4f72a2e 100644 --- a/test/expectedJson.ts +++ b/test/expectedJson.ts @@ -2206,8 +2206,13 @@ export default { }, "RT-268":{ - "html": ``, - "json": + "html": [ + ``, + `

`, + `

This is for testing purpose

`, + `

This is for testing purpose

` + ], + "json": [ { "id": "a4794fb7214745a2a47fc24104b762f9", "type": "docs", @@ -2231,9 +2236,116 @@ export default { ] } ] + }, + { + "uid": "a59f9108e99747d4b3358d9e22b7c685", + "type": "doc", + "attrs": { + "dirty": true + }, + "children": [ + { + "uid": "a41aede53efe46018e00de52b6d0970e", + "type": "reference", + "attrs": { + "display-type": "display", + "asset-uid": "***REMOVED***", + "content-type-uid": "sys_assets", + "asset-link": "https://***REMOVED***.***REMOVED***.com/v3/assets/***REMOVED***1/***REMOVED***/6572c368e7a0d4196d105010/compass-logo-v2-final.png", + "asset-name": "compass-logo-v2-final.png", + "asset-type": "image/png", + "type": "asset", + "class-name": "embedded-asset", + "alt": "", + "asset-alt": "compass-logo-v2-final.png", + "inline": false + }, + "children": [ + { + "text": "" + } + ] + } + ], + "_version": 2 + }, + { + "uid": "a59f9108e99747d4b3358d9e22b7c685", + "type": "doc", + "attrs": { + "dirty": true + }, + "children": [ + { + "uid": "8e7309d3c617401898f45c1c3ae62f1e", + "type": "p", + "attrs": { + "style": {}, + "redactor-attributes": {}, + "dir": "" + }, + "children": [ + { + "text": "This is for testing purpose" + } + ] + } + ], + "_version": 2 + }, + { + "uid": "a59f9108e99747d4b3358d9e22b7c685", + "type": "doc", + "attrs": { + "dirty": true + }, + "children": [ + { + "uid": "e22e5bcaa65b41beb3cc48a8d8cf175c", + "type": "img", + "attrs": { + "url": "https://images.contentstack.io/v3/assets/blta29a98d37041ffc4/blt0f2e045a5f4ae8bd/646df9c6b8153a80eb810a6e/tony-litvyak-PzZQFFeRt54-unsplash.jpg", + "width": 100, + "dirty": true, + "style": { + "text-align": "left", + "width": "100px", + "max-width": "100px", + "float": "left" + }, + "redactor-attributes": { + "position": "left", + "alt": "" + }, + "dir": "", + "alt": "", + "max-width": 100, + "height": 150 + }, + "children": [ + { + "text": "" + } + ] + }, + { + "uid": "8e7309d3c617401898f45c1c3ae62f1e", + "type": "p", + "attrs": { + "style": {}, + "redactor-attributes": {}, + "dir": "" + }, + "children": [ + { + "text": "This is for testing purpose" + } + ] + } + ], + "_version": 2 } - - + ] } } \ No newline at end of file diff --git a/test/toRedactor.test.ts b/test/toRedactor.test.ts index 6246b3e..86a5203 100644 --- a/test/toRedactor.test.ts +++ b/test/toRedactor.test.ts @@ -292,10 +292,29 @@ describe("Testing json to html conversion", () => { expect(html).toBe(`Infographic showing 3 results from Forrester study of Contentstack CMS: $3M increase in profit, $507.3K productivity savings and $2.0M savings due to reduced time to publish.`) }) - test(' should retain empty string value for alt attribute', () => { - const json = expectedValue['RT-268'].json; - const html = toRedactor(json); - expect(html).toBe(expectedValue['RT-268'].html); + describe("RT-268", ()=>{ + it(' should retain empty string value for alt attribute for img type', () => { + const json = expectedValue['RT-268'].json[0]; + const html = toRedactor(json); + expect(html).toBe(expectedValue['RT-268'].html[0]); + }) + it(' should retain empty string value for alt attribute for asset reference', () => { + const json = expectedValue['RT-268'].json[1]; + const html = toRedactor(json); + expect(html).toBe(expectedValue['RT-268'].html[1]); + }) + it(' should retain empty string value for attributes passed through "allowedEmptyAttributes" prop', () => { + const json = expectedValue['RT-268'].json[2]; + const html = toRedactor(json, {allowedEmptyAttributes: { p: ["dir"]} }); + expect(html).toBe(expectedValue['RT-268'].html[2]); + }) + it(' should retain empty string value for attributes passed through "allowedEmptyAttributes" prop, where alt is empty too (default empty)', () => { + const json = expectedValue['RT-268'].json[3]; + const html = toRedactor(json, {allowedEmptyAttributes: { "img": ['dir'],"p": ["dir"]} }); + expect(html).toBe(expectedValue['RT-268'].html[3]); }) + + }) + }) From 7b61a9cb5f0c41aea2b928296201ca8cbf6d0721 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Thu, 13 Mar 2025 12:05:13 +0530 Subject: [PATCH 11/66] chore: code optimisation --- src/toRedactor.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index 6f3cf9f..a99585c 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -227,7 +227,7 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string Object.keys(options.allowedEmptyAttributes).forEach(key => { if (key === 'img' || key === 'reference') { ALLOWED_EMPTY_ATTRIBUTES[key] = [ - 'alt', + ...ALLOWED_EMPTY_ATTRIBUTES[key], ...(options.allowedEmptyAttributes?.[key] || []) ]; } else { @@ -523,24 +523,21 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string delete attrsJson['url'] } delete attrsJson['redactor-attributes'] + Object.entries(attrsJson).forEach((item) => { if (forbiddenAttrChars.some(char => item[0].includes(char))) { return; } - if (ALLOWED_EMPTY_ATTRIBUTES.hasOwnProperty(jsonValue['type'])) { - if (ALLOWED_EMPTY_ATTRIBUTES[jsonValue['type']].includes(item[0])) { - // Check for 'display-type' attribute for reference type, as refernce is used for entries and assets - if (jsonValue['type'] === 'reference' && jsonValue.attrs['display-type'] === 'display') { + + if (ALLOWED_EMPTY_ATTRIBUTES.hasOwnProperty(jsonValue['type']) && ALLOWED_EMPTY_ATTRIBUTES[jsonValue['type']].includes(item[0])) { + if ( jsonValue['type'] !== 'reference' || (jsonValue['type'] === 'reference' && jsonValue.attrs['display-type'] === 'display')) { attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" `; return; - } - attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" `; - return; - } + } } return item[1] ? (item[1] !== '' ? (attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" `) : '') : '' }) - + attrs = (attrs.trim() ? ' ' : '') + attrs.trim() } if (jsonValue['type'] === 'table') { From 78b8f2604725bc5642538531f8f615b641d3c743 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Wed, 19 Mar 2025 12:12:26 +0530 Subject: [PATCH 12/66] feat: hr as a tag not data type --- src/toRedactor.tsx | 2 +- test/expectedJson.ts | 3 ++- test/fromRedactor.test.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index ca5abde..9f5ba17 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -105,7 +105,7 @@ const ELEMENT_TYPES: IJsonToHtmlElementTags = { return `${child}` }, hr: (attrs: any, child: any) => { - return `
` + return `
` }, span: (attrs: any, child: any) => { return `${child}` diff --git a/test/expectedJson.ts b/test/expectedJson.ts index a77a1d5..7e4d2d6 100644 --- a/test/expectedJson.ts +++ b/test/expectedJson.ts @@ -618,7 +618,7 @@ export default { "htmlUpdated": "

" }, "7": { - "html": "

this is link

paragraph with class

paragraph with id

", + "html": "

this is link


paragraph with class

paragraph with id

", "json": [ { "type": "p", @@ -660,6 +660,7 @@ export default { }, { "type": "hr", + "attrs": {}, "uid": "699946dbb6b84ef583914eb92dcac44b", "children": [ { diff --git a/test/fromRedactor.test.ts b/test/fromRedactor.test.ts index 8f9c92e..5c1fa63 100644 --- a/test/fromRedactor.test.ts +++ b/test/fromRedactor.test.ts @@ -66,7 +66,7 @@ describe("Testing html to json conversion", () => { let htmlDoc = dom.window.document.querySelector('body') let jsonValue = fromRedactor(htmlDoc) let testResult = isEqual(omitdeep(jsonValue, "uid"), omitdeep(docWrapper(expectedValue[7].json), "uid")) - expect(testResult).toBe(true) + expect(omitdeep(jsonValue, "uid")).toStrictEqual(omitdeep(docWrapper(expectedValue[7].json), "uid")) }) it("Embedded entry as link", () => { From c520ddc23564dea82c1769134acd725a92c2fbef Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Wed, 19 Mar 2025 13:07:08 +0530 Subject: [PATCH 13/66] fix: optimise conditions added --- src/toRedactor.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index a99585c..036a923 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -225,14 +225,10 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string } if (options?.allowedEmptyAttributes && !isEmpty(options.allowedEmptyAttributes)) { Object.keys(options.allowedEmptyAttributes).forEach(key => { - if (key === 'img' || key === 'reference') { - ALLOWED_EMPTY_ATTRIBUTES[key] = [ - ...ALLOWED_EMPTY_ATTRIBUTES[key], - ...(options.allowedEmptyAttributes?.[key] || []) - ]; - } else { - ALLOWED_EMPTY_ATTRIBUTES[key] = options.allowedEmptyAttributes?.[key] ?? []; - } + ALLOWED_EMPTY_ATTRIBUTES[key] = [ + ...(ALLOWED_EMPTY_ATTRIBUTES[key] ?? []), + ...(options.allowedEmptyAttributes?.[key] || []) + ]; }); } if (jsonValue.hasOwnProperty('text')) { From 883020bdf5dfd3f59e4a8a4fab2cf8e0b29fd475 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Wed, 19 Mar 2025 13:15:46 +0530 Subject: [PATCH 14/66] fix: optimise conditions added --- src/toRedactor.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index 036a923..21ae3d0 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -525,11 +525,9 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string return; } - if (ALLOWED_EMPTY_ATTRIBUTES.hasOwnProperty(jsonValue['type']) && ALLOWED_EMPTY_ATTRIBUTES[jsonValue['type']].includes(item[0])) { - if ( jsonValue['type'] !== 'reference' || (jsonValue['type'] === 'reference' && jsonValue.attrs['display-type'] === 'display')) { - attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" `; - return; - } + if (ALLOWED_EMPTY_ATTRIBUTES.hasOwnProperty(jsonValue['type']) && ALLOWED_EMPTY_ATTRIBUTES[jsonValue['type']].includes(item[0])) { + attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" `; + return; } return item[1] ? (item[1] !== '' ? (attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" `) : '') : '' }) From 549d5148451c674d19f032c100a03923ae91097f Mon Sep 17 00:00:00 2001 From: shreya-kamble <92981124+shreya-kamble@users.noreply.github.com> Date: Wed, 19 Mar 2025 14:32:22 +0530 Subject: [PATCH 15/66] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c981325..baf7955 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/json-rte-serializer", - "version": "2.1.0", + "version": "3.0.0", "description": "This Package converts Html Document to Json and vice-versa.", "main": "lib/index.js", "module": "lib/index.mjs", From 24a36c937d7435676cd868b0edb8f3d99e5e6fe9 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Mon, 7 Apr 2025 12:41:32 +0530 Subject: [PATCH 16/66] fix: br tags added as per no.of \n --- src/toRedactor.tsx | 2 +- test/expectedJson.ts | 73 +++++++++++++++++++++++++++++++++++++++-- test/toRedactor.test.ts | 13 ++++++++ 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index 57f7b98..0cdfc24 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -234,7 +234,7 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string if (jsonValue.hasOwnProperty('text')) { let text = jsonValue['text'].replace(//g, '>') if (jsonValue['break']) { - text += `
` + text = text.replace(/\n/g, '
') } if(jsonValue['classname'] || jsonValue['id']){ if(jsonValue['classname'] && jsonValue['id']){ diff --git a/test/expectedJson.ts b/test/expectedJson.ts index bed8a98..57f2a8a 100644 --- a/test/expectedJson.ts +++ b/test/expectedJson.ts @@ -1870,8 +1870,7 @@ export default { expectedJson: {"type":"doc","uid":"uid","attrs":{},"children":[{"type":"table","attrs":{"style":{},"redactor-attributes":{"border":"1"},"rows":5,"cols":4,"colWidths":[250,250,250,250],"disabledCols":[1,2,3]},"uid":"uid","children":[{"type":"thead","attrs":{},"uid":"uid","children":[{"type":"tr","attrs":{},"uid":"uid","children":[{"type":"th","attrs":{},"uid":"uid","children":[{"text":"Header 1"}]},{"type":"th","attrs":{"colSpan":3,"style":{},"redactor-attributes":{"colspan":"3"}},"uid":"uid","children":[{"text":"Header 2"}]},{"type":"th","attrs":{"void":true},"children":[{"text":""}]},{"type":"th","attrs":{"void":true},"children":[{"text":""}]},{"type":"th","attrs":{},"uid":"uid","children":[{"text":"Header 3"}]}]}]},{"type":"tbody","attrs":{},"uid":"uid","children":[{"type":"trgrp","children":[{"type":"tr","attrs":{},"uid":"uid","children":[{"type":"td","attrs":{"rowSpan":2,"style":{},"redactor-attributes":{"rowspan":"2"}},"uid":"uid","children":[{"text":"Row 1, Col 1"}]},{"type":"td","attrs":{"colSpan":2,"style":{},"redactor-attributes":{"colspan":"2"}},"uid":"uid","children":[{"text":"Row 1, Col 2"}]},{"type":"td","attrs":{"void":true},"children":[{"text":""}]},{"type":"td","attrs":{},"uid":"uid","children":[{"text":"Row 1, Col 3"}]},{"type":"td","attrs":{"rowSpan":2,"style":{},"redactor-attributes":{"rowspan":"2"}},"uid":"uid","children":[{"text":"Row 1, Col 4"}]}]},{"type":"tr","attrs":{},"uid":"uid","children":[{"type":"td","attrs":{"void":true},"children":[{"text":""}]},{"type":"td","attrs":{},"uid":"uid","children":[{"text":"Row 2, Col 2"}]},{"type":"td","attrs":{},"uid":"uid","children":[{"text":"Row 2, Col 3"}]},{"type":"td","attrs":{},"uid":"uid","children":[{"text":"Row 2, Col 4"}]},{"type":"td","attrs":{"void":true},"children":[{"text":""}]}]}]},{"type":"trgrp","children":[{"type":"tr","attrs":{},"uid":"uid","children":[{"type":"td","attrs":{},"uid":"uid","children":[{"text":"Row 3, Col 1"}]},{"type":"td","attrs":{"rowSpan":2,"colSpan":3,"style":{},"redactor-attributes":{"colspan":"3","rowspan":"2"}},"uid":"uid","children":[{"text":"Row 3, Col 2"}]},{"type":"td","attrs":{"void":true},"children":[{"text":""}]},{"type":"td","attrs":{"void":true},"children":[{"text":""}]},{"type":"td","attrs":{},"uid":"uid","children":[{"text":"Row 3, Col 5"}]}]},{"type":"tr","attrs":{},"uid":"uid","children":[{"type":"td","attrs":{},"uid":"uid","children":[{"text":"Row 4, Col 1"}]},{"type":"td","attrs":{"void":true},"children":[{"text":""}]},{"type":"td","attrs":{"void":true},"children":[{"text":""}]},{"type":"td","attrs":{"void":true},"children":[{"text":""}]},{"type":"td","attrs":{},"uid":"uid","children":[{"text":"Row 4, Col 5"}]}]}]}]}]}]} }, 'fix_EB-745' : { - html: `
  • Vehicle -
    This is test
`, + html: `
  • Vehicle
    This is test
`, expectedJson: { "uid": "uid", "type": "doc", @@ -2347,6 +2346,76 @@ export default { "_version": 2 } ] + }, + "RT-483":{ + "html" : [ + `

HI

Hi shift enter x2

`, + `


HI

Hi
shift enter x2

` + ], + "json" : [ + { + "uid": "a59f9108e99747d4b3358d9e22b7c685", + "type": "doc", + "attrs": { + "dirty": true + }, + "children": [ + { + "type": "p", + "uid": "bedef68ac71c4f41bdee935e78fb7c31", + "attrs": { + "style": {}, + "redactor-attributes": {}, + "dir": "ltr" + }, + "children": [ + { + "text": "HI" + }, + { + "text": "\n\n", + "break": true + }, + { + "text": "Hi shift enter x2" + } + ] + } + ], + "_version": 2 + }, + { + "uid": "a59f9108e99747d4b3358d9e22b7c685", + "type": "doc", + "attrs": { + "dirty": true + }, + "children": [ + { + "type": "p", + "uid": "bedef68ac71c4f41bdee935e78fb7c31", + "attrs": { + "style": {}, + "redactor-attributes": {}, + "dir": "ltr" + }, + "children": [ + { + "text": "\nHI" + }, + { + "text": "\n\n", + "break": true + }, + { + "text": "Hi \n shift enter x2" + } + ] + } + ], + "_version": 2 + } + ] } } \ No newline at end of file diff --git a/test/toRedactor.test.ts b/test/toRedactor.test.ts index 86a5203..c1a10ae 100644 --- a/test/toRedactor.test.ts +++ b/test/toRedactor.test.ts @@ -316,5 +316,18 @@ describe("Testing json to html conversion", () => { }) + describe("RT-483",()=>{ + it("should enter br tags equivalent to no.of times 'shift+enter' is pressed",()=>{ + const json = expectedValue['RT-483'].json[0]; + const html = toRedactor(json); + expect(html).toBe(expectedValue['RT-483'].html[0]); + }) + it("should replace /n withing other texts to br tags",()=>{ + const json = expectedValue['RT-483'].json[1]; + const html = toRedactor(json); + expect(html).toBe(expectedValue['RT-483'].html[1]); + }) + }) + }) From 08fa67901d50109c3db936f8097490299c6b123d Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Mon, 7 Apr 2025 12:44:15 +0530 Subject: [PATCH 17/66] chore: package version upgrade --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index baf7955..e353a44 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/json-rte-serializer", - "version": "3.0.0", + "version": "3.0.1", "description": "This Package converts Html Document to Json and vice-versa.", "main": "lib/index.js", "module": "lib/index.mjs", From d2ddd7336ecfebc41a02d3a19a1ea5d5e49d6a54 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Wed, 9 Apr 2025 16:09:24 +0530 Subject: [PATCH 18/66] fix: headings format returned in markdown was not as expected --- src/jsonToMarkdown.tsx | 12 ++++++------ test/expectedMarkdown.ts | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/jsonToMarkdown.tsx b/src/jsonToMarkdown.tsx index 1a7b84b..3ec4f03 100644 --- a/src/jsonToMarkdown.tsx +++ b/src/jsonToMarkdown.tsx @@ -14,32 +14,32 @@ const ELEMENT_TYPES: IJsonToMarkdownElementTags = { 'h1': (attrs: any, child: string) => { return ` -#${child}#` +# ${child} #` }, 'h2': (attrs: any, child: any) => { return ` -##${child}##` +## ${child} ##` }, 'h3': (attrs: any, child: any) => { return ` -###${child}###` +### ${child} ###` }, 'h4': (attrs: any, child: any) => { return ` -####${child}####` +#### ${child} ####` }, 'h5': (attrs: any, child: any) => { return ` -#####${child}#####` +##### ${child} #####` }, 'h6': (attrs: any, child: any) => { return ` -######${child}######` +###### ${child} ######` }, img: (attrsJson: any, child: any) => { if(attrsJson) { diff --git a/test/expectedMarkdown.ts b/test/expectedMarkdown.ts index 4baf5cb..ab701d5 100644 --- a/test/expectedMarkdown.ts +++ b/test/expectedMarkdown.ts @@ -163,17 +163,17 @@ This is a paragraph with \`inline code\`.` }], "markdown": ` -#Heading 1# +# Heading 1 # -##Heading 2## +## Heading 2 ## -###Heading 3### +### Heading 3 ### -####Heading 4#### +#### Heading 4 #### -#####Heading 5##### +##### Heading 5 ##### -######Heading 6######` +###### Heading 6 ######` }, { "title": "Block Quote Conversion", From cc732e593d3ec6818f3b3168d5f8585d2519d0ea Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Tue, 15 Apr 2025 18:20:36 +0530 Subject: [PATCH 19/66] sca-scan.yml From 904b869c40bc36c77bfcc73af6a228265cdeae93 Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Tue, 15 Apr 2025 18:20:40 +0530 Subject: [PATCH 20/66] policy-scan.yml --- .github/workflows/policy-scan.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/policy-scan.yml diff --git a/.github/workflows/policy-scan.yml b/.github/workflows/policy-scan.yml new file mode 100644 index 0000000..13bd362 --- /dev/null +++ b/.github/workflows/policy-scan.yml @@ -0,0 +1,27 @@ +name: Checks the security policy and configurations +on: + pull_request: + types: [opened, synchronize, reopened] +jobs: + security-policy: + if: github.event.repository.visibility == 'public' + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@master + - name: Checks for SECURITY.md policy file + run: | + if ! [[ -f "SECURITY.md" || -f ".github/SECURITY.md" ]]; then exit 1; fi + security-license: + if: github.event.repository.visibility == 'public' + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@master + - name: Checks for License file + run: | + if ! [[ -f "LICENSE" || -f "License.txt" || -f "LICENSE.md" ]]; then exit 1; fi \ No newline at end of file From d5920defbfd03108157e5386926d5fb5985f0415 Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Tue, 15 Apr 2025 18:20:50 +0530 Subject: [PATCH 21/66] issues-jira.yml --- .github/workflows/issues-jira.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/issues-jira.yml diff --git a/.github/workflows/issues-jira.yml b/.github/workflows/issues-jira.yml new file mode 100644 index 0000000..7bf0469 --- /dev/null +++ b/.github/workflows/issues-jira.yml @@ -0,0 +1,31 @@ +name: Create Jira Ticket for Github Issue + +on: + issues: + types: [opened] + +jobs: + issue-jira: + runs-on: ubuntu-latest + steps: + + - name: Login to Jira + uses: atlassian/gajira-login@master + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + + - name: Create Jira Issue + id: create_jira + uses: atlassian/gajira-create@master + with: + project: ${{ secrets.JIRA_PROJECT }} + issuetype: ${{ secrets.JIRA_ISSUE_TYPE }} + summary: Github | Issue | ${{ github.event.repository.name }} | ${{ github.event.issue.title }} + description: | + *GitHub Issue:* ${{ github.event.issue.html_url }} + + *Description:* + ${{ github.event.issue.body }} + fields: "${{ secrets.ISSUES_JIRA_FIELDS }}" \ No newline at end of file From 30c2d3c4c294178ded743f22c38a8eb31b2e5c2c Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Tue, 15 Apr 2025 18:20:51 +0530 Subject: [PATCH 22/66] Delete jira.yml --- .github/workflows/jira.yml | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 .github/workflows/jira.yml diff --git a/.github/workflows/jira.yml b/.github/workflows/jira.yml deleted file mode 100644 index 250abc7..0000000 --- a/.github/workflows/jira.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Create JIRA ISSUE -on: - pull_request: - types: [opened] -jobs: - security-jira: - if: ${{ github.actor == 'dependabot[bot]' || github.actor == 'snyk-bot' || contains(github.event.pull_request.head.ref, 'snyk-fix-') || contains(github.event.pull_request.head.ref, 'snyk-upgrade-')}} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Login into JIRA - uses: atlassian/gajira-login@master - env: - JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} - JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} - JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} - - name: Create a JIRA Issue - id: create - uses: atlassian/gajira-create@master - with: - project: ${{ secrets.JIRA_PROJECT }} - issuetype: ${{ secrets.JIRA_ISSUE_TYPE }} - summary: | - Snyk | Vulnerability | ${{ github.event.repository.name }} | ${{ github.event.pull_request.title }} - description: | - PR: ${{ github.event.pull_request.html_url }} - - fields: "${{ secrets.JIRA_FIELDS }}" - - name: Transition issue - uses: atlassian/gajira-transition@v3 - with: - issue: ${{ steps.create.outputs.issue }} - transition: ${{ secrets.JIRA_TRANSITION }} From c50e04b880d093e4584d7ea50c194405d514c7e7 Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Tue, 15 Apr 2025 18:20:52 +0530 Subject: [PATCH 23/66] Delete sast-scan.yml --- .github/workflows/sast-scan.yml | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .github/workflows/sast-scan.yml diff --git a/.github/workflows/sast-scan.yml b/.github/workflows/sast-scan.yml deleted file mode 100644 index 3b9521a..0000000 --- a/.github/workflows/sast-scan.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: SAST Scan -on: - pull_request: - types: [opened, synchronize, reopened] -jobs: - security-sast: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Semgrep Scan - run: docker run -v /var/run/docker.sock:/var/run/docker.sock -v "${PWD}:/src" returntocorp/semgrep semgrep scan --config auto \ No newline at end of file From b1a00ef1d4505cebd61a529a83463c0b8bff661e Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Tue, 15 Apr 2025 18:20:54 +0530 Subject: [PATCH 24/66] codeql-analysis.yml From 9a5a865f517fa16e00d1846f465598d7537cea4e Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Tue, 15 Apr 2025 18:20:59 +0530 Subject: [PATCH 25/66] Updated codeowners From 6f65b90691fed76250d7b263126410adb4cd6686 Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Wed, 16 Apr 2025 13:07:48 +0530 Subject: [PATCH 26/66] policy-scan.yml From debd44700913bb89fbaf692af6b7d4dbc64170e5 Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Wed, 16 Apr 2025 13:07:59 +0530 Subject: [PATCH 27/66] issues-jira.yml From 11d79457feecacfc157022429be8cac9086aa71f Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Wed, 16 Apr 2025 13:08:01 +0530 Subject: [PATCH 28/66] codeql-analysis.yml From addb625812ed641f20de168ba84a40732cb97552 Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Wed, 16 Apr 2025 13:08:06 +0530 Subject: [PATCH 29/66] Updated codeowners From dd71d4d4d53503f56674983f0a34f855eb28f078 Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Wed, 23 Apr 2025 21:46:07 +0530 Subject: [PATCH 30/66] policy-scan.yml --- .github/workflows/policy-scan.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/policy-scan.yml b/.github/workflows/policy-scan.yml index 13bd362..ff25923 100644 --- a/.github/workflows/policy-scan.yml +++ b/.github/workflows/policy-scan.yml @@ -24,4 +24,23 @@ jobs: - uses: actions/checkout@master - name: Checks for License file run: | - if ! [[ -f "LICENSE" || -f "License.txt" || -f "LICENSE.md" ]]; then exit 1; fi \ No newline at end of file + expected_license_files=("LICENSE" "LICENSE.txt" "LICENSE.md" "License.txt") + license_file_found=false + current_year=$(date +"%Y") + + for license_file in "${expected_license_files[@]}"; do + if [ -f "$license_file" ]; then + license_file_found=true + # check the license file for the current year, if not exists, exit with error + if ! grep -q "$current_year" "$license_file"; then + echo "License file $license_file does not contain the current year." + exit 2 + fi + break + fi + done + + if [ "$license_file_found" = false ]; then + echo "No license file found. Please add a license file to the repository." + exit 1 + fi \ No newline at end of file From a730a5042e03b17380747464d0a5265f81d20112 Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Mon, 5 May 2025 21:38:19 +0530 Subject: [PATCH 31/66] policy-scan.yml From a92d2538a6376ec069a476068863070b7b8c8a0c Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Mon, 5 May 2025 21:38:28 +0530 Subject: [PATCH 32/66] issues-jira.yml From e295b6b499c10a0aefa789697459b665eba22ccb Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Mon, 5 May 2025 21:38:30 +0530 Subject: [PATCH 33/66] secrets-scan.yml --- .github/workflows/secrets-scan.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/secrets-scan.yml diff --git a/.github/workflows/secrets-scan.yml b/.github/workflows/secrets-scan.yml new file mode 100644 index 0000000..049c02f --- /dev/null +++ b/.github/workflows/secrets-scan.yml @@ -0,0 +1,29 @@ +name: Secrets Scan +on: + pull_request: + types: [opened, synchronize, reopened] +jobs: + security-secrets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: '2' + ref: '${{ github.event.pull_request.head.ref }}' + - run: | + git reset --soft HEAD~1 + - name: Install Talisman + run: | + # Download Talisman + wget https://github.com/thoughtworks/talisman/releases/download/v1.37.0/talisman_linux_amd64 -O talisman + + # Checksum verification + checksum=$(sha256sum ./talisman | awk '{print $1}') + if [ "$checksum" != "8e0ae8bb7b160bf10c4fa1448beb04a32a35e63505b3dddff74a092bccaaa7e4" ]; then exit 1; fi + + # Make it executable + chmod +x talisman + - name: Run talisman + run: | + # Run Talisman with the pre-commit hook + ./talisman --githook pre-commit \ No newline at end of file From 1afa4c71edf8ab01cf27ef56602ac0f896a8e5c5 Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Mon, 5 May 2025 21:38:35 +0530 Subject: [PATCH 34/66] Updated codeowners From 407773e6940cae6030e02ba76c35ac03d75d944a Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Mon, 5 May 2025 23:25:17 +0530 Subject: [PATCH 35/66] talismanrc file updated --- .talismanrc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.talismanrc b/.talismanrc index 3fd67ab..d04c06a 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,4 +1,7 @@ fileignoreconfig: +- filename: .github/workflows/secrets-scan.yml + ignore_detectors: + - filecontent - filename: README.md checksum: 4bffa07e1e88d50a311464c9f90ff92bcba7fe434be4d9374bcd49bf76b1e6a8 - filename: coverage/lcov-report/base.css From b8f20a50e6921b6fab41c7a96056ef7d9a85b10e Mon Sep 17 00:00:00 2001 From: "manoj.muduli" Date: Tue, 13 May 2025 18:06:08 +0530 Subject: [PATCH 36/66] Titel attr added on A element --- src/fromRedactor.tsx | 4 +++ test/expectedJson.ts | 71 +++++++++++++++++++++++++++++++++++++++ test/fromRedactor.test.ts | 9 +++++ test/toRedactor.test.ts | 11 ++++++ 4 files changed, 95 insertions(+) diff --git a/src/fromRedactor.tsx b/src/fromRedactor.tsx index da4ef56..e33f9e0 100644 --- a/src/fromRedactor.tsx +++ b/src/fromRedactor.tsx @@ -20,12 +20,16 @@ export const ELEMENT_TAGS: IHtmlToJsonElementTags = { const attrs: Record = {} const target = el.getAttribute('target'); const href = el.getAttribute('href'); + const title = el.getAttribute('title'); attrs.url = href ? href : '#'; if(target && target !== '') { attrs.target = target; } + if(title && title !== '') { + attrs.title = title; + } return { type: "a", diff --git a/test/expectedJson.ts b/test/expectedJson.ts index 57f2a8a..796a240 100644 --- a/test/expectedJson.ts +++ b/test/expectedJson.ts @@ -2416,6 +2416,77 @@ export default { "_version": 2 } ] + }, + "RT-501":{ + "html" : [ + `

ABC

` + ], + "json" : [ + { + "type": "doc", + "attrs": {}, + "uid": "bd63f151aa8d402cae046c8dae440134", + "children": [ + { + "type": "p", + "uid": "d2949ce0e0974ce783543edd37410c71", + "attrs": {}, + "children": [ + { + "uid": "7a2fd904668447ca8720428cbd2b0acc", + "type": "a", + "attrs": { + "url": "google.in", + "target": "_blank", + "title": "google" + }, + "children": [ + { + "text": "ABC" + } + ] + } + ] + } + ], + } + ], + "jsonWithRedactorAttributes": [ + { + "type": "doc", + "attrs": {}, + "uid": "bd63f151aa8d402cae046c8dae440134", + "children": [ + { + "type": "p", + "uid": "d2949ce0e0974ce783543edd37410c71", + "attrs": {}, + "children": [ + { + "uid": "7a2fd904668447ca8720428cbd2b0acc", + "type": "a", + "attrs": { + "url": "google.in", + "target": "_blank", + "title": "google", + "style": {}, + "redactor-attributes": { + "href": "google.in", + "target": "_blank", + "title": "google" + } + }, + "children": [ + { + "text": "ABC" + } + ] + } + ] + } + ] + } + ] } } \ No newline at end of file diff --git a/test/fromRedactor.test.ts b/test/fromRedactor.test.ts index 5c1fa63..878e102 100644 --- a/test/fromRedactor.test.ts +++ b/test/fromRedactor.test.ts @@ -328,6 +328,15 @@ describe("Testing html to json conversion", () => { expect(json).toStrictEqual({"type":"doc","uid":"uid","attrs":{},"children":[{"type":"p","attrs":{},"uid":"uid","children":[{"text":"Hello","attrs":{"style":{}},"bold":true},{"text":" Hii"}]}]}) }) + test("should add title attr to anchor tag", () => { + const html = expectedValue["RT-501"].html[0]; + const json =expectedValue["RT-501"].jsonWithRedactorAttributes[0]; + + let jsonValue = htmlToJson(html) + let testResult = isEqual(omitdeep(jsonValue, "uid"), omitdeep(json, "uid")) + + expect(testResult).toBe(true) + }) }) diff --git a/test/toRedactor.test.ts b/test/toRedactor.test.ts index c1a10ae..3054e13 100644 --- a/test/toRedactor.test.ts +++ b/test/toRedactor.test.ts @@ -329,5 +329,16 @@ describe("Testing json to html conversion", () => { }) }) + describe("RT-501",()=>{ + it("should add title attr to anchor tag",()=>{ + const html = expectedValue["RT-501"].html[0]; + const json =expectedValue["RT-501"].json[0]; + + let htmlValue = toRedactor(json) + expect(htmlValue).toBe(html); + }) + + }) + }) From 98f33c4fd4d86c08cbb100fb7ae429ae467b529a Mon Sep 17 00:00:00 2001 From: "manoj.muduli" Date: Tue, 13 May 2025 18:24:45 +0530 Subject: [PATCH 37/66] resolve PR comment --- test/fromRedactor.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/fromRedactor.test.ts b/test/fromRedactor.test.ts index 878e102..a145ad7 100644 --- a/test/fromRedactor.test.ts +++ b/test/fromRedactor.test.ts @@ -333,9 +333,7 @@ describe("Testing html to json conversion", () => { const json =expectedValue["RT-501"].jsonWithRedactorAttributes[0]; let jsonValue = htmlToJson(html) - let testResult = isEqual(omitdeep(jsonValue, "uid"), omitdeep(json, "uid")) - - expect(testResult).toBe(true) + expect(omitdeep(jsonValue, "uid")).toStrictEqual(omitdeep(json, "uid")) }) }) From 281e2317309197d0476e59d45884ebef7711a8f4 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Wed, 21 May 2025 13:08:48 +0530 Subject: [PATCH 38/66] feat: preserve empty blocks while converting to html --- .talismanrc | 2 ++ README.md | 34 ++++++++++++++++++++++++++++++---- src/toRedactor.tsx | 12 +++++++++++- src/types.ts | 4 +++- test/toRedactor.test.ts | 6 ++++++ 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/.talismanrc b/.talismanrc index 3fd67ab..66437b9 100644 --- a/.talismanrc +++ b/.talismanrc @@ -21,3 +21,5 @@ fileignoreconfig: checksum: 3d014702628ad538065c970d988a695af003c61663596a8f6b9267b4e57ef6ea - filename: test/expectedJson.json checksum: 9979f84be3e5aa27f24381a0c49e0e6696388d19615c4f3b09082780968236ee +- filename: README.md + checksum: cccb3cd93c499acc87593eca5cc032e256c11cf530d4de67ece09e57fc430215 diff --git a/README.md b/README.md index 55df54b..49bdad2 100644 --- a/README.md +++ b/README.md @@ -161,12 +161,27 @@ On the other hand, the `customTextWrapper` parser function provides the followin - `child`: The HTML string that specifies the child element - `value`: The value passed against the child element +___ -You can pass an object to `allowedEmptyAttributes` to retain empty attribute values for specific element types during HTML conversion. + `allowedEmptyAttributes` -**Note:** -By default, if nothing is passed to `allowedEmptyAttributes`, we retain the `alt` attribute for `` and `reference` (asset) element types, even when its value is empty, during HTML conversion. +- Type: `object` +- Default: `{ img: ['alt'], reference: ['alt'] }` +Specifies which empty attributes should be retained for specific HTML elements during the jsonToHtml conversion. +By default, the converter preserves the alt attribute for and reference (asset) elements, even when their values are empty. +This is particularly useful for ensuring semantic correctness and accessibility. + +Use this option when you want to retain specific attributes with empty values during the conversion process. + +___ + + `addNbspForEmptyBlocks` + +- Type: `boolean` +- Default:`false` + +When set to true, this option adds a non-breaking space (nbsp;) to empty blocks during the jsonToHtml conversion. This helps maintain the visual structure of the HTML output—especially useful for preserving spacing in editable content or content editors. You can use the following customized JSON RTE Serializer code to convert your JSON RTE field data into HTML format. @@ -196,6 +211,16 @@ const jsonValue = { }, ], }, + { + "type": "p", + "uid": "28c837c127504d3c85b9cb6d7099cb0b", + "attrs": {}, + "children": [ + { + "text": "" + } + ] + }, { type: "p", attrs: {}, @@ -225,7 +250,8 @@ const htmlValue = jsonToHtml( allowedEmptyAttributes : { "p": ["dir"], "img" : ["width"] - } + }, + addNbspForEmptyBlocks : true } ); diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index 0cdfc24..4d39395 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -218,8 +218,13 @@ const ALLOWED_EMPTY_ATTRIBUTES: IJsonToHtmlAllowedEmptyAttributes = { reference: ['alt'] } +let ADD_NBSP_FOR_EMPTY_BLOCKS : boolean = false + export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string => { //TODO: optimize assign once per function call + if(options?.addNbspForEmptyBlocks){ + ADD_NBSP_FOR_EMPTY_BLOCKS = options?.addNbspForEmptyBlocks + } if(options?.customTextWrapper && !isEmpty(options.customTextWrapper)){ Object.assign(TEXT_WRAPPERS,options.customTextWrapper) } @@ -590,7 +595,12 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string attrs = (attrs.trim() ? ' ' : '') + attrs.trim() - return ELEMENT_TYPES[orgType || jsonValue['type']](attrs, children,jsonValue, figureStyles) + return ELEMENT_TYPES[orgType || jsonValue['type']]( + attrs, + ADD_NBSP_FOR_EMPTY_BLOCKS && !children ? ' ' : children, + jsonValue, + figureStyles + ) } return children diff --git a/src/types.ts b/src/types.ts index e531ecf..11404b4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,8 @@ export interface IAnyObject {[key:string]:any} export interface IHtmlToJsonOptions { allowNonStandardTags?: boolean, customElementTags?: IHtmlToJsonElementTags, - customTextTags?: IHtmlToJsonTextTags + customTextTags?: IHtmlToJsonTextTags, + addNbspForEmptyBlocks?: boolean } export interface IHtmlToJsonElementTagsAttributes { type:string, @@ -22,4 +23,5 @@ export interface IJsonToHtmlOptions { customTextWrapper?: IJsonToHtmlTextTags, allowNonStandardTypes?: boolean, allowedEmptyAttributes?: IJsonToHtmlAllowedEmptyAttributes, + addNbspForEmptyBlocks?: boolean } diff --git a/test/toRedactor.test.ts b/test/toRedactor.test.ts index c1a10ae..b45eab6 100644 --- a/test/toRedactor.test.ts +++ b/test/toRedactor.test.ts @@ -331,3 +331,9 @@ describe("Testing json to html conversion", () => { }) +test("should add nbsp for empty blocks", () => { + const json = {"type":"doc","uid":"uid","attrs":{},"children":[{"type":"p","attrs":{},"uid":"uid","children":[{"text":"Hi"}]},{"type":"p","attrs":{},"uid":"uid","children":[{"text":""}]},{"type":"p","attrs":{},"uid":"uid","children":[{"text":""}]},{"type":"p","attrs":{},"uid":"uid","children":[{"text":"Hello"}]}]}; + const html = toRedactor(json, {addNbspForEmptyBlocks: true}); + expect(html).toBe(`

Hi

 

 

Hello

`); +}); + From 6037a82db4912d31e1015439724c720d4f1d6a7c Mon Sep 17 00:00:00 2001 From: shreya-kamble <92981124+shreya-kamble@users.noreply.github.com> Date: Wed, 21 May 2025 13:21:46 +0530 Subject: [PATCH 39/66] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e353a44..2314fe5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/json-rte-serializer", - "version": "3.0.1", + "version": "3.0.2", "description": "This Package converts Html Document to Json and vice-versa.", "main": "lib/index.js", "module": "lib/index.mjs", From ae88512c451e037ef4dabf932b8f7aeb4e0aaf34 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Thu, 22 May 2025 12:20:15 +0530 Subject: [PATCH 40/66] fix: remove invisible psace added in json text in case of nbsp --- src/fromRedactor.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/fromRedactor.tsx b/src/fromRedactor.tsx index da4ef56..c26fc10 100644 --- a/src/fromRedactor.tsx +++ b/src/fromRedactor.tsx @@ -206,6 +206,9 @@ export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject if (el.textContent === '\n') { return null } + if (options?.addNbspForEmptyBlocks && el.textContent.trim() === '') { + return { text: '' } + } if (el.parentNode.nodeName === 'SPAN') { let attrs = { style: {} } const metadata = {} @@ -297,6 +300,7 @@ export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject let children: any = flatten(Array.from(parent.childNodes).map((child) => fromRedactor(child, options))) children = children.filter((child: any) => child !== null) children = traverseChildAndWarpChild(children, options?.allowNonStandardTags) + if (children.length === 0) { children = [{ text: '' }] } From 1c21b049c078247ebeca4ab0c3595ad42c9fb1b0 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Thu, 22 May 2025 12:31:03 +0530 Subject: [PATCH 41/66] chore: add skipchecks in talismanrc --- .talismanrc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.talismanrc b/.talismanrc index fee9575..baebc93 100644 --- a/.talismanrc +++ b/.talismanrc @@ -26,3 +26,5 @@ fileignoreconfig: checksum: 9979f84be3e5aa27f24381a0c49e0e6696388d19615c4f3b09082780968236ee - filename: README.md checksum: cccb3cd93c499acc87593eca5cc032e256c11cf530d4de67ece09e57fc430215 +- filename: test/expectedJson.ts + checksum: a1966b0b3993c8e3a0e9e45de49204e7788ba74ba0089a8a6b6eba0729f990bd From 8cda1833bc048065ed017cffdb6ca04694ae9b0e Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Thu, 12 Jun 2025 15:59:19 +0530 Subject: [PATCH 42/66] fix: rt-531 --- src/toRedactor.tsx | 3 +-- test/toRedactor.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index 4d39395..02ab067 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -60,7 +60,7 @@ const ELEMENT_TYPES: IJsonToHtmlElementTags = { return `${child}` }, code: (attrs: any, child: any) => { - return `${child}` + return `${child.replace(//g, '\n')}` }, li: (attrs: any, child: any) => { return `${child}` @@ -602,7 +602,6 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string figureStyles ) } - return children } diff --git a/test/toRedactor.test.ts b/test/toRedactor.test.ts index 854a6db..97997c7 100644 --- a/test/toRedactor.test.ts +++ b/test/toRedactor.test.ts @@ -348,3 +348,8 @@ test("should add nbsp for empty blocks", () => { expect(html).toBe(`

Hi

 

 

Hello

`); }); +test("should convert codeblock to proper html, where \n should not be replaced with
",()=>{ + const json = {"type":"doc","uid":"uid","attrs":{},"children":[{"type":"code","attrs":{},"uid":"uid","children":[{"text":"Hi\nHello"}]}]}; + const html = toRedactor(json); + expect(html).toBe(`
Hi\nHello
`); +}) From f12235e238a94708828e3efb883ee36d1165dfa1 Mon Sep 17 00:00:00 2001 From: shreya-kamble <92981124+shreya-kamble@users.noreply.github.com> Date: Thu, 12 Jun 2025 16:03:32 +0530 Subject: [PATCH 43/66] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2314fe5..46f84fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/json-rte-serializer", - "version": "3.0.2", + "version": "3.0.3", "description": "This Package converts Html Document to Json and vice-versa.", "main": "lib/index.js", "module": "lib/index.mjs", From af9212c98670700c1f01396003c0a158edebcc74 Mon Sep 17 00:00:00 2001 From: Nishant Naithani Date: Tue, 19 Aug 2025 13:28:58 +0530 Subject: [PATCH 44/66] [RT-611][bug] Moved the constants to separate file and used them to create local vars to make changes --- src/constants/index.ts | 517 +++++++++++++++++++++++++++++++++++++++++ src/fromRedactor.tsx | 143 +----------- src/jsonToMarkdown.tsx | 153 +----------- src/toRedactor.tsx | 240 ++----------------- 4 files changed, 543 insertions(+), 510 deletions(-) create mode 100644 src/constants/index.ts diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..cd5f230 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,517 @@ +import isEmpty from 'lodash.isempty' +import { IJsonToMarkdownElementTags, IJsonToMarkdownTextTags, IHtmlToJsonElementTags, IHtmlToJsonTextTags, IJsonToHtmlElementTags, IJsonToHtmlTextTags, IJsonToHtmlAllowedEmptyAttributes } from '../types' + +export const listTypes = ['ol', 'ul']; + +export const elementsToAvoidWithinMarkdownTable = [ + 'ol', + 'ul', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'blockquote', + 'code', + 'reference', + 'img', + 'fragment' +]; + +export const ELEMENT_TYPES: IJsonToMarkdownElementTags = { + 'blockquote': (attrs: any, child: any) => { + return ` + +> ${child}` + }, + 'h1': (attrs: any, child: string) => { + return ` + +# ${child} #` + }, + 'h2': (attrs: any, child: any) => { + return ` + +## ${child} ##` + }, + 'h3': (attrs: any, child: any) => { + return ` + +### ${child} ###` + }, + 'h4': (attrs: any, child: any) => { + return ` + +#### ${child} ####` + }, + 'h5': (attrs: any, child: any) => { + return ` + +##### ${child} #####` + }, + 'h6': (attrs: any, child: any) => { + return ` + +###### ${child} ######` + }, + img: (attrsJson: any, child: any) => { + if(attrsJson) { + let imageAlt = attrsJson?.['alt'] ? attrsJson['alt'] : 'enter image description here' + let imageURL = attrsJson?.['url'] ? attrsJson['url'] : '' + return ` + +![${imageAlt}] +(${imageURL})` + } + return '' + }, + p: (attrs: any, child: any) => { + return ` + +${child}` + }, + code: (attrs: any, child: any) => { + return ` + + ${child} ` + }, + ol: (attrs: any, child: any) => { + return `${child}` + }, + ul: (attrs: any, child: any) => { + return `${child}` + }, + li: (attrs: any, child: any) => { + return `${child}` + }, + a: (attrsJson: any, child: any) => { + return `[${child}](${attrsJson.url})` + }, + hr: (attrs: any, child: any) => { + return ` + +----------` + }, + span: (attrs: any, child: any) => { + return `${child}` + }, + reference: (attrsJson: any, child: any): any => { + if(attrsJson?.['display-type'] === 'display') { + if(attrsJson) { + let assetName = attrsJson?.['asset-name'] ? attrsJson['asset-name'] : 'enter image description here' + let assetURL = attrsJson?.['asset-link'] ? attrsJson['asset-link'] : '' + return ` + +![${assetName}] +(${assetURL})` + } + } + else if(attrsJson?.['display-type'] === 'link') { + if(attrsJson) { + return `[${child}](${attrsJson?.['href'] ? attrsJson['href'] : "#"})` + } + } + }, + fragment: (attrs: any, child: any) => { + return child + }, + table: (attrs: any, child: any) => { + return `${child}` + }, + tbody: (attrs: any, child: any) => { + return `${child}` + }, + thead: (attrs: any, child: any) => { + let tableBreak = '|' + if(attrs.cols) { + if(attrs.addEmptyThead) { + let tHeadChildren = '| ' + for(let i = 0; i < attrs.cols; i++) { + tHeadChildren += '| ' + tableBreak += ' ----- |' + } + return `${tHeadChildren}\n${tableBreak}\n` + } + else{ + for(let i = 0; i < attrs.cols; i++) { + tableBreak += ' ----- |' + } + return `${child}\n${tableBreak}\n` + } + } + + return `${child}` + }, + tr: (attrs: any, child: any) => { + return `| ${child}\n` + }, + td: (attrs: any, child: any) => { + return ` ${child.trim()} |` + }, + th: (attrs: any, child: any) => { + return ` ${child.trim()} |` + } +}; + +export const TEXT_WRAPPERS: IJsonToMarkdownTextTags = { + 'bold': (child: any, value: any) => { + return `**${child}**`; + }, + 'italic': (child: any, value: any) => { + return `*${child}*`; + }, + 'strikethrough': (child: any, value: any) => { + return `~~${child}~~`; + }, + 'inlineCode': (child: any, value: any) => { + return `\`${child}\`` + }, +}; + +export const ELEMENT_TAGS: IHtmlToJsonElementTags = { + A: (el: HTMLElement) => { + const attrs: Record = {} + const target = el.getAttribute('target'); + const href = el.getAttribute('href'); + const title = el.getAttribute('title'); + + attrs.url = href ? href : '#'; + + if(target && target !== '') { + attrs.target = target; + } + if(title && title !== '') { + attrs.title = title; + } + + return { + type: "a", + attrs: attrs, + }; + }, + BLOCKQUOTE: () => ({ type: 'blockquote', attrs: {} }), + H1: () => ({ type: 'h1', attrs: {} }), + H2: () => ({ type: 'h2', attrs: {} }), + H3: () => ({ type: 'h3', attrs: {} }), + H4: () => ({ type: 'h4', attrs: {} }), + H5: () => ({ type: 'h5', attrs: {} }), + H6: () => ({ type: 'h6', attrs: {} }), + IMG: (el: HTMLElement) => { + let imageUrl = el.getAttribute('src')?.split(".") || ["png"] + let imageType = imageUrl[imageUrl?.length - 1] + const assetUid = el.getAttribute('asset_uid') + if(assetUid){ + + const splittedUrl = el.getAttribute('src')?.split('/')! || [null] + const assetName = splittedUrl[splittedUrl?.length - 1] + return { type: 'reference', attrs: { "asset-name": assetName,"content-type-uid" : "sys_assets", "asset-link": el.getAttribute('src'), "asset-type": `image/${imageType}`, "display-type": "display", "type": "asset", "asset-uid": assetUid } } + } + const imageAttrs : any = { type: 'img', attrs: { url: el.getAttribute('src') } } + if (el.getAttribute('width')) { + imageAttrs.attrs['width'] = el.getAttribute('width') + } + return imageAttrs + }, + LI: () => ({ type: 'li', attrs: {} }), + OL: () => ({ type: 'ol', attrs: {} }), + P: () => ({ type: 'p', attrs: {} }), + PRE: () => ({ type: 'code', attrs: {} }), + UL: () => ({ type: 'ul', attrs: {} }), + IFRAME: (el: HTMLElement) => { + if(el.getAttribute('data-type') === "social-embeds") { + const src = el.getAttribute('src') + el.removeAttribute('data-type') + el.removeAttribute('src') + return { type: 'social-embeds', attrs: { src } } + } + return { type: 'embed', attrs: { src: el.getAttribute('src') } } + }, + TABLE: (el: HTMLElement) => ({ type: 'table', attrs: {} }), + THEAD: (el: HTMLElement) => ({ type: 'thead', attrs: {} }), + TBODY: (el: HTMLElement) => ({ type: 'tbody', attrs: {} }), + TR: (el: HTMLElement) => ({ type: 'tr', attrs: {} }), + TD: (el: HTMLElement) => ({ type: 'td', attrs: { ...spanningAttrs(el) } }), + TH: (el: HTMLElement) => ({ type: 'th', attrs: { ...spanningAttrs(el) } }), + // FIGURE: (el: HTMLElement) => ({ type: 'reference', attrs: { default: true, "display-type": "display", "type": "asset" } }), + + FIGURE: (el: HTMLElement) => { + if (el.lastChild && el.lastChild.nodeName === 'P') { + return { type: 'figure', attrs: {} } + } + else { + return { type: 'img', attrs: {} } + } + + }, + SPAN: (el: HTMLElement) => { + return { type: 'span', attrs: {} } + }, + DIV: (el: HTMLElement) => { + return { type: 'div', attrs: {} } + }, + VIDEO: (el: HTMLElement) => { + const srcArray = Array.from(el.querySelectorAll("source")).map((source) => + source.getAttribute("src") + ); + + return { + type: 'embed', + attrs: { + src: srcArray.length > 0 ? srcArray[0] : null, + }, + } + }, + STYLE: (el: HTMLElement) => { + return { type: 'style', attrs: { "style-text": el.textContent } } + }, + SCRIPT: (el: HTMLElement) => { + return { type: 'script', attrs: {} } + }, + HR: () => ({ type: 'hr', attrs: {} }), + FIGCAPTION: () => ({ type: 'figcaption', attrs: {} }), +} + +const spanningAttrs = (el: HTMLElement) => { + const attrs = {} + const rowSpan = parseInt(el.getAttribute('rowspan') ?? '1') + const colSpan = parseInt(el.getAttribute('colspan') ?? '1') + if (rowSpan > 1) attrs['rowSpan'] = rowSpan + if (colSpan > 1) attrs['colSpan'] = colSpan + + return attrs +} + +export const TEXT_TAGS: IHtmlToJsonTextTags = { + CODE: () => ({ code: true }), + DEL: () => ({ strikethrough: true }), + EM: () => ({ italic: true }), + I: () => ({ italic: true }), + S: () => ({ strikethrough: true }), + STRONG: () => ({ bold: true }), + B: () => ({ bold: true }), + U: () => ({ underline: true }), + SUP: () => ({ superscript: true }), + SUB: () => ({ subscript: true }) +} + +export const HTML_ELEMENT_TYPES: IJsonToHtmlElementTags = { + 'blockquote': (attrs: string, child: string) => { + return `${child}` + }, + 'h1': (attrs, child) => { + return `${child}` + }, + 'h2': (attrs: any, child: any) => { + return `${child}` + }, + 'h3': (attrs: any, child: any) => { + return `${child}` + }, + 'h4': (attrs: any, child: any) => { + return `${child}` + }, + 'h5': (attrs: any, child: any) => { + return `${child}` + }, + 'h6': (attrs: any, child: any) => { + return `${child}` + }, + img: (attrs: any, child: any,jsonBlock: any, figureStyles: any) => { + if (figureStyles.fieldsEdited.length === 0) { + return `` + } + let img = figureStyles.anchorLink ? `` : `` + let caption = figureStyles.caption + ? figureStyles.alignment === 'center' + ? `
${figureStyles.caption}
` + : `
${figureStyles.caption}
` + : '' + let align = figureStyles.position + ? `
${img}${caption}
` + : figureStyles.caption + ? `
${img}${caption}
` + : `${img}` + + return `${align}` + }, + + embed: (attrs: any, child: any) => { + return `` + }, + p: (attrs: any, child: any) => { + if(child.includes("${child}` + return `${child}

` + }, + ol: (attrs: any, child: any) => { + return `${child}` + }, + ul: (attrs: any, child: any) => { + return `${child}` + }, + code: (attrs: any, child: any) => { + return `${child.replace(//g, '\n')}` + }, + li: (attrs: any, child: any) => { + return `${child}` + }, + a: (attrs: any, child: any) => { + return `${child}` + }, + table: (attrs: any, child: any) => { + return `${child}` + }, + tbody: (attrs: any, child: any) => { + return `${child}` + }, + thead: (attrs: any, child: any) => { + return `${child}` + }, + tr: (attrs: any, child: any) => { + return `${child}` + }, + trgrp: (attrs: any, child: any) => { + return child + }, + td: (attrs: any, child: any) => { + return `${child}` + }, + th: (attrs: any, child: any) => { + return `${child}` + }, + 'check-list': (attrs: any, child: any) => { + return `${child}

` + }, + row: (attrs: any, child: any) => { + return `${child}` + }, + column: (attrs: any, child: any) => { + return `${child}` + }, + 'grid-container': (attrs: any, child: any) => { + return `${child}` + }, + 'grid-child': (attrs: any, child: any) => { + return `${child}` + }, + hr: (attrs: any, child: any) => { + return `
` + }, + span: (attrs: any, child: any) => { + return `${child}` + }, + div: (attrs: any, child: any) => { + return `${child}` + }, + reference: (attrs: any, child: any, jsonBlock: any, extraAttrs: any) => { + if (extraAttrs?.displayType === 'inline') { + return `${child}` + } else if (extraAttrs?.displayType === 'block') { + return `${child}` + } else if (extraAttrs?.displayType === 'link') { + return `${child}` + } else if (extraAttrs?.displayType === 'asset') { + return `${child}` + } + + else if (extraAttrs?.displayType === "display") { + const anchor = jsonBlock?.["attrs"]?.["link"] ?? jsonBlock?.["attrs"]?.["anchorLink"]; + + const caption = jsonBlock?.["attrs"]?.["asset-caption"]; + const position = jsonBlock?.["attrs"]?.["position"]; + const inline = jsonBlock?.["attrs"]?.["inline"] + let figureAttrs = "" + const figureStyles: { [key: string]: string } = { + margin: "0", + }; + if(!attrs.includes(`src="${jsonBlock?.["attrs"]?.["asset-link"]}`)){ + attrs = ` src="${jsonBlock?.["attrs"]?.["asset-link"]}"` + attrs; + } + let img = ``; + + if (anchor) { + const target = jsonBlock?.["attrs"]?.["target"]; + let anchorAttrs = `href="${anchor}"`; + if (target) { + anchorAttrs = `${anchorAttrs} target="${target}"`; + } + img = `${img}`; + } + + if (caption || (position && position !== "none")) { + const figcaption = caption + ? `
${caption}
` + : ""; + + if (inline && position !== "right" && position !== "left") { + figureStyles["display"] = "inline-block"; + } + if (position && position !== "none") { + figureStyles[inline ? "float" : "text-align"] = position; + } + + if(figcaption){ + img = `
${img}${figcaption}
`; + } + } + if(!isEmpty(figureStyles)){ + figureAttrs = ` style="${Object.keys(figureStyles).map((key) => `${key}: ${figureStyles[key]}`).join("; ")}"` + } + if(inline && !caption && (!position ||position==='none')){ + return img + } + return `${img}`; + } + return `${child}` + }, + inlineCode: (attrs: any, child: any) => { + return "" + }, + fragment: (attrs: any, child: any) => { + return child + }, + style: (attrs: any, child: any) => { + return `` + }, + script: (attrs: any, child: any) => { + return `` + }, + "social-embeds": (attrs: any, child: any) => { + return `` + } +} + +export const HTML_TEXT_WRAPPERS: IJsonToHtmlTextTags = { + 'bold': (child: any, value:any) => { + return `${child}`; + }, + 'italic': (child: any, value:any) => { + return `${child}`; + }, + 'underline': (child: any, value:any) => { + return `${child}`; + }, + 'strikethrough': (child: any, value:any) => { + return `${child}`; + }, + 'superscript': (child: any, value:any) => { + return `${child}`; + }, + 'subscript': (child: any, value:any) => { + return `${child}`; + }, + 'inlineCode': (child: any, value:any) => { + return `${child}` + }, +} + +export const ALLOWED_EMPTY_ATTRIBUTES: IJsonToHtmlAllowedEmptyAttributes = { + img: ['alt'], + reference: ['alt'] +} + +export const isInline = ['span', 'a', 'inlineCode', 'reference'] +export const isVoid = ['img', 'embed'] + +export const whiteCharPattern = /^[\s ]{2,}$/ diff --git a/src/fromRedactor.tsx b/src/fromRedactor.tsx index 07021ff..7179bb5 100644 --- a/src/fromRedactor.tsx +++ b/src/fromRedactor.tsx @@ -7,129 +7,12 @@ import cloneDeep from "lodash/cloneDeep" import isUndefined from "lodash/isUndefined" import { jsx } from './utils/jsx' +import { ELEMENT_TAGS, TEXT_TAGS, isInline, isVoid, whiteCharPattern } from './constants' import {IHtmlToJsonElementTags,IHtmlToJsonOptions, IHtmlToJsonTextTags, IAnyObject} from './types' const generateId = () => v4().split('-').join('') -const isInline = ['span', 'a', 'inlineCode', 'reference'] -const isVoid = ['img', 'embed'] - -export const ELEMENT_TAGS: IHtmlToJsonElementTags = { - A: (el: HTMLElement) => { - const attrs: Record = {} - const target = el.getAttribute('target'); - const href = el.getAttribute('href'); - const title = el.getAttribute('title'); - - attrs.url = href ? href : '#'; - - if(target && target !== '') { - attrs.target = target; - } - if(title && title !== '') { - attrs.title = title; - } - - return { - type: "a", - attrs: attrs, - }; - }, - BLOCKQUOTE: () => ({ type: 'blockquote', attrs: {} }), - H1: () => ({ type: 'h1', attrs: {} }), - H2: () => ({ type: 'h2', attrs: {} }), - H3: () => ({ type: 'h3', attrs: {} }), - H4: () => ({ type: 'h4', attrs: {} }), - H5: () => ({ type: 'h5', attrs: {} }), - H6: () => ({ type: 'h6', attrs: {} }), - IMG: (el: HTMLElement) => { - let imageUrl = el.getAttribute('src')?.split(".") || ["png"] - let imageType = imageUrl[imageUrl?.length - 1] - const assetUid = el.getAttribute('asset_uid') - if(assetUid){ - - const splittedUrl = el.getAttribute('src')?.split('/')! || [null] - const assetName = splittedUrl[splittedUrl?.length - 1] - return { type: 'reference', attrs: { "asset-name": assetName,"content-type-uid" : "sys_assets", "asset-link": el.getAttribute('src'), "asset-type": `image/${imageType}`, "display-type": "display", "type": "asset", "asset-uid": assetUid } } - } - const imageAttrs : any = { type: 'img', attrs: { url: el.getAttribute('src') } } - if (el.getAttribute('width')) { - imageAttrs.attrs['width'] = el.getAttribute('width') - } - return imageAttrs - }, - LI: () => ({ type: 'li', attrs: {} }), - OL: () => ({ type: 'ol', attrs: {} }), - P: () => ({ type: 'p', attrs: {} }), - PRE: () => ({ type: 'code', attrs: {} }), - UL: () => ({ type: 'ul', attrs: {} }), - IFRAME: (el: HTMLElement) => { - if(el.getAttribute('data-type') === "social-embeds") { - const src = el.getAttribute('src') - el.removeAttribute('data-type') - el.removeAttribute('src') - return { type: 'social-embeds', attrs: { src } } - } - return { type: 'embed', attrs: { src: el.getAttribute('src') } } - }, - TABLE: (el: HTMLElement) => ({ type: 'table', attrs: {} }), - THEAD: (el: HTMLElement) => ({ type: 'thead', attrs: {} }), - TBODY: (el: HTMLElement) => ({ type: 'tbody', attrs: {} }), - TR: (el: HTMLElement) => ({ type: 'tr', attrs: {} }), - TD: (el: HTMLElement) => ({ type: 'td', attrs: { ...spanningAttrs(el) } }), - TH: (el: HTMLElement) => ({ type: 'th', attrs: { ...spanningAttrs(el) } }), - // FIGURE: (el: HTMLElement) => ({ type: 'reference', attrs: { default: true, "display-type": "display", "type": "asset" } }), - - FIGURE: (el: HTMLElement) => { - if (el.lastChild && el.lastChild.nodeName === 'P') { - return { type: 'figure', attrs: {} } - } - else { - return { type: 'img', attrs: {} } - } - - }, - SPAN: (el: HTMLElement) => { - return { type: 'span', attrs: {} } - }, - DIV: (el: HTMLElement) => { - return { type: 'div', attrs: {} } - }, - VIDEO: (el: HTMLElement) => { - const srcArray = Array.from(el.querySelectorAll("source")).map((source) => - source.getAttribute("src") - ); - - return { - type: 'embed', - attrs: { - src: srcArray.length > 0 ? srcArray[0] : null, - }, - } - }, - STYLE: (el: HTMLElement) => { - return { type: 'style', attrs: { "style-text": el.textContent } } - }, - SCRIPT: (el: HTMLElement) => { - return { type: 'script', attrs: {} } - }, - HR: () => ({ type: 'hr', attrs: {} }), - FIGCAPTION: () => ({ type: 'figcaption', attrs: {} }), -} - -const TEXT_TAGS: IHtmlToJsonTextTags = { - CODE: () => ({ code: true }), - DEL: () => ({ strikethrough: true }), - EM: () => ({ italic: true }), - I: () => ({ italic: true }), - S: () => ({ strikethrough: true }), - STRONG: () => ({ bold: true }), - B: () => ({ bold: true }), - U: () => ({ underline: true }), - SUP: () => ({ superscript: true }), - SUB: () => ({ subscript: true }) -} const trimChildString = (child: any) => { if (typeof child === 'string') { return child.trim() !== '' @@ -201,8 +84,9 @@ const traverseChildAndWarpChild = (children: Array, allowNonStandardTags return children } -const whiteCharPattern = /^[\s ]{2,}$/ export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject | null => { + let localElementTags: IHtmlToJsonElementTags = ELEMENT_TAGS; + let localTextTags: IHtmlToJsonTextTags = TEXT_TAGS; // If node is text node if (el.nodeType === 3) { if (whiteCharPattern.test(el.textContent)) return null @@ -293,12 +177,13 @@ export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject } const { nodeName } = el let parent = el + if(el.nodeName === "BODY"){ if(options?.customElementTags && !isEmpty(options.customElementTags)){ - Object.assign(ELEMENT_TAGS, options.customElementTags) + localElementTags = { ...localElementTags, ...options.customElementTags }; } if(options?.customTextTags && !isEmpty(options.customTextTags)) { - Object.assign(TEXT_TAGS, options.customTextTags) + localTextTags = { ...localTextTags, ...options.customTextTags }; } } let children: any = flatten(Array.from(parent.childNodes).map((child) => fromRedactor(child, options))) @@ -317,7 +202,7 @@ export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject } return jsx('element', { type: "doc", uid: generateId(), attrs: {} }, children) } - if (options?.allowNonStandardTags && !Object.keys(ELEMENT_TAGS).includes(nodeName) && !Object.keys(TEXT_TAGS).includes(nodeName)) { + if (options?.allowNonStandardTags && !Object.keys(localElementTags).includes(nodeName) && !Object.keys(TEXT_TAGS).includes(nodeName)) { const attributes = (el as HTMLElement).attributes const attributeMap = {} Array.from(attributes).forEach((attribute) => { @@ -535,7 +420,7 @@ export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject } } - if (ELEMENT_TAGS[nodeName]) { + if (localElementTags[nodeName]) { if (el.nodeName === 'P') { children = children.map((child: any) => { if (typeof child === 'string') { @@ -553,7 +438,7 @@ export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject return null } - let elementAttrs = ELEMENT_TAGS[nodeName](el) + let elementAttrs = localElementTags[nodeName](el) const attributes = el.attributes if (attributes.length !== 0) { const attribute = Array.from(attributes).map(getDomAttributes) @@ -1014,16 +899,6 @@ export const getNestedValueIfAvailable = (value: string) => { } }; - -const spanningAttrs = (el: HTMLElement) => { - const attrs = {} - const rowSpan = parseInt(el.getAttribute('rowspan') ?? '1') - const colSpan = parseInt(el.getAttribute('colspan') ?? '1') - if (rowSpan > 1) attrs['rowSpan'] = rowSpan - if (colSpan > 1) attrs['colSpan'] = colSpan - - return attrs -} const emptyCell = (cellType: string, attrs = {}) => { return jsx('element', { type: cellType, attrs: { void: true, ...attrs } }, [{ text: '' }]) } diff --git a/src/jsonToMarkdown.tsx b/src/jsonToMarkdown.tsx index 3ec4f03..cc2d7fc 100644 --- a/src/jsonToMarkdown.tsx +++ b/src/jsonToMarkdown.tsx @@ -1,158 +1,7 @@ import {IJsonToMarkdownElementTags, IJsonToMarkdownTextTags} from './types' import {cloneDeep} from 'lodash' import {Node} from 'slate' - -let listTypes = ['ol', 'ul'] -const elementsToAvoidWithinMarkdownTable = ['ol', 'ul', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'reference', 'img', 'fragment'] - -const ELEMENT_TYPES: IJsonToMarkdownElementTags = { - 'blockquote': (attrs: any, child: any) => { - return ` - -> ${child}` - }, - 'h1': (attrs: any, child: string) => { - return ` - -# ${child} #` - }, - 'h2': (attrs: any, child: any) => { - return ` - -## ${child} ##` - }, - 'h3': (attrs: any, child: any) => { - return ` - -### ${child} ###` - }, - 'h4': (attrs: any, child: any) => { - return ` - -#### ${child} ####` - }, - 'h5': (attrs: any, child: any) => { - return ` - -##### ${child} #####` - }, - 'h6': (attrs: any, child: any) => { - return ` - -###### ${child} ######` - }, - img: (attrsJson: any, child: any) => { - if(attrsJson) { - let imageAlt = attrsJson?.['alt'] ? attrsJson['alt'] : 'enter image description here' - let imageURL = attrsJson?.['url'] ? attrsJson['url'] : '' - return ` - -![${imageAlt}] -(${imageURL})` - } - return '' - }, - p: (attrs: any, child: any) => { - return ` - -${child}` - }, - code: (attrs: any, child: any) => { - return ` - - ${child} ` - }, - ol: (attrs: any, child: any) => { - return `${child}` - }, - ul: (attrs: any, child: any) => { - return `${child}` - }, - li: (attrs: any, child: any) => { - return `${child}` - }, - a: (attrsJson: any, child: any) => { - return `[${child}](${attrsJson.url})` - }, - hr: (attrs: any, child: any) => { - return ` - -----------` - }, - span: (attrs: any, child: any) => { - return `${child}` - }, - reference: (attrsJson: any, child: any): any => { - if(attrsJson?.['display-type'] === 'display') { - if(attrsJson) { - let assetName = attrsJson?.['asset-name'] ? attrsJson['asset-name'] : 'enter image description here' - let assetURL = attrsJson?.['asset-link'] ? attrsJson['asset-link'] : '' - return ` - -![${assetName}] -(${assetURL})` - } - } - else if(attrsJson?.['display-type'] === 'link') { - if(attrsJson) { - return `[${child}](${attrsJson?.['href'] ? attrsJson['href'] : "#"})` - } - } - }, - fragment: (attrs: any, child: any) => { - return child - }, - table: (attrs: any, child: any) => { - return `${child}` - }, - tbody: (attrs: any, child: any) => { - return `${child}` - }, - thead: (attrs: any, child: any) => { - let tableBreak = '|' - if(attrs.cols) { - if(attrs.addEmptyThead) { - let tHeadChildren = '| ' - for(let i = 0; i < attrs.cols; i++) { - tHeadChildren += '| ' - tableBreak += ' ----- |' - } - return `${tHeadChildren}\n${tableBreak}\n` - } - else{ - for(let i = 0; i < attrs.cols; i++) { - tableBreak += ' ----- |' - } - return `${child}\n${tableBreak}\n` - } - } - - return `${child}` - }, - tr: (attrs: any, child: any) => { - return `| ${child}\n` - }, - td: (attrs: any, child: any) => { - return ` ${child.trim()} |` - }, - th: (attrs: any, child: any) => { - return ` ${child.trim()} |` - } -} -const TEXT_WRAPPERS: IJsonToMarkdownTextTags = { - 'bold': (child: any, value: any) => { - return `**${child}**`; - }, - 'italic': (child: any, value: any) => { - return `*${child}*`; - }, - 'strikethrough': (child: any, value: any) => { - return `~~${child}~~`; - }, - 'inlineCode': (child: any, value: any) => { - return `\`${child}\`` - }, -} +import { listTypes, elementsToAvoidWithinMarkdownTable, ELEMENT_TYPES, TEXT_WRAPPERS } from './constants' const getOLOrULStringFromJson = (value: any) => { let child = '' diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index 02ab067..f0abc5b 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -3,220 +3,7 @@ import isEmpty from 'lodash.isempty' import {IJsonToHtmlElementTags, IJsonToHtmlOptions, IJsonToHtmlTextTags, IJsonToHtmlAllowedEmptyAttributes} from './types' import isPlainObject from 'lodash.isplainobject' import {replaceHtmlEntities, forbiddenAttrChars } from './utils' - -const ELEMENT_TYPES: IJsonToHtmlElementTags = { - 'blockquote': (attrs: string, child: string) => { - return `${child}` - }, - 'h1': (attrs, child) => { - return `${child}` - }, - 'h2': (attrs: any, child: any) => { - return `${child}` - }, - 'h3': (attrs: any, child: any) => { - return `${child}` - }, - 'h4': (attrs: any, child: any) => { - return `${child}` - }, - 'h5': (attrs: any, child: any) => { - return `${child}` - }, - 'h6': (attrs: any, child: any) => { - return `${child}` - }, - img: (attrs: any, child: any,jsonBlock: any, figureStyles: any) => { - if (figureStyles.fieldsEdited.length === 0) { - return `` - } - let img = figureStyles.anchorLink ? `` : `` - let caption = figureStyles.caption - ? figureStyles.alignment === 'center' - ? `
${figureStyles.caption}
` - : `
${figureStyles.caption}
` - : '' - let align = figureStyles.position - ? `
${img}${caption}
` - : figureStyles.caption - ? `
${img}${caption}
` - : `${img}` - - return `${align}` - }, - - embed: (attrs: any, child: any) => { - return `` - }, - p: (attrs: any, child: any) => { - if(child.includes("${child}` - return `${child}

` - }, - ol: (attrs: any, child: any) => { - return `${child}` - }, - ul: (attrs: any, child: any) => { - return `${child}` - }, - code: (attrs: any, child: any) => { - return `${child.replace(//g, '\n')}` - }, - li: (attrs: any, child: any) => { - return `${child}` - }, - a: (attrs: any, child: any) => { - return `${child}` - }, - table: (attrs: any, child: any) => { - return `${child}` - }, - tbody: (attrs: any, child: any) => { - return `${child}` - }, - thead: (attrs: any, child: any) => { - return `${child}` - }, - tr: (attrs: any, child: any) => { - return `${child}` - }, - trgrp: (attrs: any, child: any) => { - return child - }, - td: (attrs: any, child: any) => { - return `${child}` - }, - th: (attrs: any, child: any) => { - return `${child}` - }, - 'check-list': (attrs: any, child: any) => { - return `${child}

` - }, - row: (attrs: any, child: any) => { - return `${child}` - }, - column: (attrs: any, child: any) => { - return `${child}` - }, - 'grid-container': (attrs: any, child: any) => { - return `${child}` - }, - 'grid-child': (attrs: any, child: any) => { - return `${child}` - }, - hr: (attrs: any, child: any) => { - return `
` - }, - span: (attrs: any, child: any) => { - return `${child}` - }, - div: (attrs: any, child: any) => { - return `${child}` - }, - reference: (attrs: any, child: any, jsonBlock: any, extraAttrs: any) => { - if (extraAttrs?.displayType === 'inline') { - return `${child}` - } else if (extraAttrs?.displayType === 'block') { - return `${child}` - } else if (extraAttrs?.displayType === 'link') { - return `${child}` - } else if (extraAttrs?.displayType === 'asset') { - return `${child}` - } - - else if (extraAttrs?.displayType === "display") { - const anchor = jsonBlock?.["attrs"]?.["link"] ?? jsonBlock?.["attrs"]?.["anchorLink"]; - - const caption = jsonBlock?.["attrs"]?.["asset-caption"]; - const position = jsonBlock?.["attrs"]?.["position"]; - const inline = jsonBlock?.["attrs"]?.["inline"] - let figureAttrs = "" - const figureStyles = { - margin: "0", - }; - if(!attrs.includes(`src="${jsonBlock?.["attrs"]?.["asset-link"]}`)){ - attrs = ` src="${jsonBlock?.["attrs"]?.["asset-link"]}"` + attrs; - } - let img = ``; - - if (anchor) { - const target = jsonBlock?.["attrs"]?.["target"]; - let anchorAttrs = `href="${anchor}"`; - if (target) { - anchorAttrs = `${anchorAttrs} target="${target}"`; - } - img = `${img}`; - } - - if (caption || (position && position !== "none")) { - const figcaption = caption - ? `
${caption}
` - : ""; - - if (inline && position !== "right" && position !== "left") { - figureStyles["display"] = "inline-block"; - } - if (position && position !== "none") { - figureStyles[inline ? "float" : "text-align"] = position; - } - - if(figcaption){ - img = `
${img}${figcaption}
`; - } - } - if(!isEmpty(figureStyles)){ - figureAttrs = ` style="${getStyleStringFromObject(figureStyles)}"` - } - if(inline && !caption && (!position ||position==='none')){ - return img - } - return `${img}`; - } - return `${child}` - }, - inlineCode: (attrs: any, child: any) => { - return "" - }, - fragment: (attrs: any, child: any) => { - return child - }, - style: (attrs: any, child: any) => { - return `` - }, - script: (attrs: any, child: any) => { - return `` - }, - "social-embeds": (attrs: any, child: any) => { - return `` - } -} -const TEXT_WRAPPERS: IJsonToHtmlTextTags = { - 'bold': (child: any, value:any) => { - return `${child}`; - }, - 'italic': (child: any, value:any) => { - return `${child}`; - }, - 'underline': (child: any, value:any) => { - return `${child}`; - }, - 'strikethrough': (child: any, value:any) => { - return `${child}`; - }, - 'superscript': (child: any, value:any) => { - return `${child}`; - }, - 'subscript': (child: any, value:any) => { - return `${child}`; - }, - 'inlineCode': (child: any, value:any) => { - return `${child}` - }, -} -const ALLOWED_EMPTY_ATTRIBUTES: IJsonToHtmlAllowedEmptyAttributes = { - img: ['alt'], - reference: ['alt'] -} +import { HTML_ELEMENT_TYPES, TEXT_WRAPPERS, ALLOWED_EMPTY_ATTRIBUTES } from './constants' let ADD_NBSP_FOR_EMPTY_BLOCKS : boolean = false @@ -225,13 +12,18 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string if(options?.addNbspForEmptyBlocks){ ADD_NBSP_FOR_EMPTY_BLOCKS = options?.addNbspForEmptyBlocks } + let localTextWrappers: IJsonToHtmlTextTags = TEXT_WRAPPERS; + let localAllowedEmptyAttributes: IJsonToHtmlAllowedEmptyAttributes = ALLOWED_EMPTY_ATTRIBUTES; + let localElementTypes: IJsonToHtmlElementTags = HTML_ELEMENT_TYPES; + if(options?.customTextWrapper && !isEmpty(options.customTextWrapper)){ - Object.assign(TEXT_WRAPPERS,options.customTextWrapper) + localTextWrappers = { ...localTextWrappers, ...options.customTextWrapper }; } if (options?.allowedEmptyAttributes && !isEmpty(options.allowedEmptyAttributes)) { + localAllowedEmptyAttributes = { ...ALLOWED_EMPTY_ATTRIBUTES }; Object.keys(options.allowedEmptyAttributes).forEach(key => { - ALLOWED_EMPTY_ATTRIBUTES[key] = [ - ...(ALLOWED_EMPTY_ATTRIBUTES[key] ?? []), + localAllowedEmptyAttributes[key] = [ + ...(localAllowedEmptyAttributes[key] ?? []), ...(options.allowedEmptyAttributes?.[key] || []) ]; }); @@ -256,8 +48,8 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string text = text.replace(/\n/g, '
') } Object.entries(jsonValue).forEach(([key, value]) => { - if(TEXT_WRAPPERS.hasOwnProperty(key)){ - text = TEXT_WRAPPERS[key](text,value) + if(localTextWrappers.hasOwnProperty(key)){ + text = localTextWrappers[key](text,value) } }) if (jsonValue['attrs']) { @@ -282,7 +74,7 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string } let children: any = '' if (options?.customElementTypes && !isEmpty(options.customElementTypes)) { - Object.assign(ELEMENT_TYPES, options.customElementTypes) + localElementTypes = { ...localElementTypes, ...options.customElementTypes }; } if (jsonValue.children) { children = Array.from(jsonValue.children).map((child) => toRedactor(child,options)) @@ -296,7 +88,7 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string } children = children.join('') } - if (options?.allowNonStandardTypes && !Object.keys(ELEMENT_TYPES).includes(jsonValue['type']) && jsonValue['type'] !== 'doc') { + if (options?.allowNonStandardTypes && !Object.keys(localElementTypes).includes(jsonValue['type']) && jsonValue['type'] !== 'doc') { let attrs = '' Object.entries(jsonValue?.attrs|| {}).forEach(([key, val]) => { if(isPlainObject(val)){ @@ -311,7 +103,7 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string console.warn(`${jsonValue['type']} is not a valid element type.`) return `<${jsonValue['type'].toLowerCase()}${attrs}>${children}` } - if (ELEMENT_TYPES[jsonValue['type']]) { + if (localElementTypes[jsonValue['type']]) { let attrs = '' let orgType let figureStyles: any = { @@ -530,7 +322,7 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string return; } - if (ALLOWED_EMPTY_ATTRIBUTES.hasOwnProperty(jsonValue['type']) && ALLOWED_EMPTY_ATTRIBUTES[jsonValue['type']].includes(item[0])) { + if (localAllowedEmptyAttributes.hasOwnProperty(jsonValue['type']) && localAllowedEmptyAttributes[jsonValue['type']].includes(item[0])) { attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" `; return; } @@ -595,7 +387,7 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string attrs = (attrs.trim() ? ' ' : '') + attrs.trim() - return ELEMENT_TYPES[orgType || jsonValue['type']]( + return localElementTypes[orgType || jsonValue['type']]( attrs, ADD_NBSP_FOR_EMPTY_BLOCKS && !children ? ' ' : children, jsonValue, From ed3abda5393f42e2d3eea70a6b16c9c509febeca Mon Sep 17 00:00:00 2001 From: Nishant Naithani Date: Tue, 19 Aug 2025 16:55:09 +0530 Subject: [PATCH 45/66] [RT-611][bug] Small fixes --- src/fromRedactor.tsx | 25 +++++++++++++------------ src/toRedactor.tsx | 4 ++-- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/fromRedactor.tsx b/src/fromRedactor.tsx index 7179bb5..21c4808 100644 --- a/src/fromRedactor.tsx +++ b/src/fromRedactor.tsx @@ -87,6 +87,14 @@ const traverseChildAndWarpChild = (children: Array, allowNonStandardTags export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject | null => { let localElementTags: IHtmlToJsonElementTags = ELEMENT_TAGS; let localTextTags: IHtmlToJsonTextTags = TEXT_TAGS; + + if (options?.customElementTags && !isEmpty(options.customElementTags)){ + localElementTags = { ...localElementTags, ...options.customElementTags }; + } + + if (options?.customTextTags && !isEmpty(options.customTextTags)) { + localTextTags = { ...localTextTags, ...options.customTextTags }; + } // If node is text node if (el.nodeType === 3) { if (whiteCharPattern.test(el.textContent)) return null @@ -178,14 +186,7 @@ export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject const { nodeName } = el let parent = el - if(el.nodeName === "BODY"){ - if(options?.customElementTags && !isEmpty(options.customElementTags)){ - localElementTags = { ...localElementTags, ...options.customElementTags }; - } - if(options?.customTextTags && !isEmpty(options.customTextTags)) { - localTextTags = { ...localTextTags, ...options.customTextTags }; - } - } + let children: any = flatten(Array.from(parent.childNodes).map((child) => fromRedactor(child, options))) children = children.filter((child: any) => child !== null) children = traverseChildAndWarpChild(children, options?.allowNonStandardTags) @@ -202,7 +203,7 @@ export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject } return jsx('element', { type: "doc", uid: generateId(), attrs: {} }, children) } - if (options?.allowNonStandardTags && !Object.keys(localElementTags).includes(nodeName) && !Object.keys(TEXT_TAGS).includes(nodeName)) { + if (options?.allowNonStandardTags && !Object.keys(localElementTags).includes(nodeName) && !Object.keys(localTextTags).includes(nodeName)) { const attributes = (el as HTMLElement).attributes const attributeMap = {} Array.from(attributes).forEach((attribute) => { @@ -757,7 +758,7 @@ export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject } let noOfInlineElement = 0 Array.from(el.parentNode?.childNodes || []).forEach((child: any) => { - if (child.nodeType === 3 || child.nodeName === 'SPAN' || child.nodeName === 'A' || (options?.allowNonStandardTags && child.getAttribute('inline')) || child.nodeName in TEXT_TAGS) { + if (child.nodeType === 3 || child.nodeName === 'SPAN' || child.nodeName === 'A' || (options?.allowNonStandardTags && child.getAttribute('inline')) || child.nodeName in localTextTags) { noOfInlineElement += 1 } }) @@ -787,8 +788,8 @@ export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject return jsx('element', elementAttrs, children) } - if (TEXT_TAGS[nodeName]) { - const attrs = TEXT_TAGS[nodeName](el) + if (localTextTags[nodeName]) { + const attrs = localTextTags[nodeName](el) let attrsStyle = { attrs: { style: {} }, ...attrs } let newChildren = children.map((child: any) => { diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index f0abc5b..6ff92a0 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -3,7 +3,7 @@ import isEmpty from 'lodash.isempty' import {IJsonToHtmlElementTags, IJsonToHtmlOptions, IJsonToHtmlTextTags, IJsonToHtmlAllowedEmptyAttributes} from './types' import isPlainObject from 'lodash.isplainobject' import {replaceHtmlEntities, forbiddenAttrChars } from './utils' -import { HTML_ELEMENT_TYPES, TEXT_WRAPPERS, ALLOWED_EMPTY_ATTRIBUTES } from './constants' +import { HTML_ELEMENT_TYPES, HTML_TEXT_WRAPPERS, ALLOWED_EMPTY_ATTRIBUTES } from './constants' let ADD_NBSP_FOR_EMPTY_BLOCKS : boolean = false @@ -12,7 +12,7 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string if(options?.addNbspForEmptyBlocks){ ADD_NBSP_FOR_EMPTY_BLOCKS = options?.addNbspForEmptyBlocks } - let localTextWrappers: IJsonToHtmlTextTags = TEXT_WRAPPERS; + let localTextWrappers: IJsonToHtmlTextTags = HTML_TEXT_WRAPPERS; let localAllowedEmptyAttributes: IJsonToHtmlAllowedEmptyAttributes = ALLOWED_EMPTY_ATTRIBUTES; let localElementTypes: IJsonToHtmlElementTags = HTML_ELEMENT_TYPES; From 8d9d71c4537f41338f9b580826912b7d73025e7e Mon Sep 17 00:00:00 2001 From: Nishant Naithani Date: Tue, 19 Aug 2025 17:40:33 +0530 Subject: [PATCH 46/66] [RT-611][bug] Test cases fix --- test/fromRedactor.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/fromRedactor.test.ts b/test/fromRedactor.test.ts index a145ad7..01b3f0e 100644 --- a/test/fromRedactor.test.ts +++ b/test/fromRedactor.test.ts @@ -1,11 +1,13 @@ // @ts-nocheck -import { ELEMENT_TAGS, fromRedactor, getNestedValueIfAvailable } from "../src/fromRedactor" +import { fromRedactor, getNestedValueIfAvailable } from "../src/fromRedactor" import { JSDOM } from "jsdom" import isEqual from "lodash.isequal" import omitdeep from "omit-deep-lodash" import expectedValue from "./expectedJson" import { IHtmlToJsonOptions } from "../src/types" +import { ELEMENT_TAGS } from "../src/constants" + const docWrapper = (children: any) => { return { "type": "doc", From 73878346700d34877720229d715ee77c432c4ec4 Mon Sep 17 00:00:00 2001 From: Nishant Naithani Date: Tue, 19 Aug 2025 17:57:23 +0530 Subject: [PATCH 47/66] [RT-611][bug] Security Scan --- src/fromRedactor.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/fromRedactor.tsx b/src/fromRedactor.tsx index 21c4808..ed232eb 100644 --- a/src/fromRedactor.tsx +++ b/src/fromRedactor.tsx @@ -85,6 +85,7 @@ const traverseChildAndWarpChild = (children: Array, allowNonStandardTags } export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject | null => { + let localElementTags: IHtmlToJsonElementTags = ELEMENT_TAGS; let localTextTags: IHtmlToJsonTextTags = TEXT_TAGS; From 9fdf694065f103bc75ce6efe1d0aa89f38a5aab1 Mon Sep 17 00:00:00 2001 From: Nishant Naithani Date: Tue, 19 Aug 2025 18:13:49 +0530 Subject: [PATCH 48/66] [RT-611][bug] Security Scan --- src/fromRedactor.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/fromRedactor.tsx b/src/fromRedactor.tsx index ed232eb..21c4808 100644 --- a/src/fromRedactor.tsx +++ b/src/fromRedactor.tsx @@ -85,7 +85,6 @@ const traverseChildAndWarpChild = (children: Array, allowNonStandardTags } export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject | null => { - let localElementTags: IHtmlToJsonElementTags = ELEMENT_TAGS; let localTextTags: IHtmlToJsonTextTags = TEXT_TAGS; From 7c38c53e921241ec5159a5140800d7e047604b33 Mon Sep 17 00:00:00 2001 From: Nishant Naithani Date: Wed, 20 Aug 2025 15:57:51 +0530 Subject: [PATCH 49/66] [RT-611][bug] Version bump --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7deee9c..4047ef4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@contentstack/json-rte-serializer", - "version": "2.0.13", + "version": "3.0.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@contentstack/json-rte-serializer", - "version": "2.0.13", + "version": "3.0.4", "license": "MIT", "dependencies": { "array-flat-polyfill": "^1.0.1", diff --git a/package.json b/package.json index 46f84fd..d2e3a7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/json-rte-serializer", - "version": "3.0.3", + "version": "3.0.4", "description": "This Package converts Html Document to Json and vice-versa.", "main": "lib/index.js", "module": "lib/index.mjs", From af490af468310738a739a430630c4b1a8a625311 Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Mon, 8 Sep 2025 19:30:08 +0530 Subject: [PATCH 50/66] Delete secrets-scan.yml --- .github/workflows/secrets-scan.yml | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 .github/workflows/secrets-scan.yml diff --git a/.github/workflows/secrets-scan.yml b/.github/workflows/secrets-scan.yml deleted file mode 100644 index 049c02f..0000000 --- a/.github/workflows/secrets-scan.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Secrets Scan -on: - pull_request: - types: [opened, synchronize, reopened] -jobs: - security-secrets: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: '2' - ref: '${{ github.event.pull_request.head.ref }}' - - run: | - git reset --soft HEAD~1 - - name: Install Talisman - run: | - # Download Talisman - wget https://github.com/thoughtworks/talisman/releases/download/v1.37.0/talisman_linux_amd64 -O talisman - - # Checksum verification - checksum=$(sha256sum ./talisman | awk '{print $1}') - if [ "$checksum" != "8e0ae8bb7b160bf10c4fa1448beb04a32a35e63505b3dddff74a092bccaaa7e4" ]; then exit 1; fi - - # Make it executable - chmod +x talisman - - name: Run talisman - run: | - # Run Talisman with the pre-commit hook - ./talisman --githook pre-commit \ No newline at end of file From 0be0338e6e193cc9d09066ea4c160313ce46c24e Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Mon, 8 Sep 2025 19:30:13 +0530 Subject: [PATCH 51/66] Updated codeowners --- CODEOWNERS | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 1be7e0d..7f7f935 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1,11 @@ -* @contentstack/security-admin +* @contentstack/ghost-pr-reviewers + +.github/workflows/sca-scan.yml @contentstack/security-admin + +.github/workflows/codeql-anaylsis.yml @contentstack/security-admin + +**/.snyk @contentstack/security-admin + +.github/workflows/policy-scan.yml @contentstack/security-admin + +.github/workflows/issues-jira.yml @contentstack/security-admin From eb158e81573f8e17f3b1ebe69aec3815679cf410 Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Wed, 24 Dec 2025 11:18:36 +0530 Subject: [PATCH 52/66] sca-scan.yml From 8758234a11a159692ba432cf68f908075556bd75 Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Wed, 24 Dec 2025 11:18:39 +0530 Subject: [PATCH 53/66] policy-scan.yml From 91acb10e7f4cf30dc58e3740e57ddb60585a7cbf Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Wed, 24 Dec 2025 11:18:47 +0530 Subject: [PATCH 54/66] issues-jira.yml From 22c86df9e5ae3add0500750d3e1a0f2c8c11b547 Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Wed, 24 Dec 2025 11:18:49 +0530 Subject: [PATCH 55/66] Updated codeowners --- CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index 7f7f935..84ceebf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,3 +1,4 @@ +* @contentstack/venus-pr-reviewers * @contentstack/ghost-pr-reviewers .github/workflows/sca-scan.yml @contentstack/security-admin @@ -9,3 +10,5 @@ .github/workflows/policy-scan.yml @contentstack/security-admin .github/workflows/issues-jira.yml @contentstack/security-admin + +.github/workflows/codeql-anaylsis.yml @contentstack/security-admin From 3a3ab9d34e3fd8fe24134f9f824f5096db6b985a Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Wed, 24 Dec 2025 11:25:13 +0530 Subject: [PATCH 56/66] Update CODEOWNERS --- CODEOWNERS | 3 --- 1 file changed, 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 84ceebf..49adcb9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,5 +1,4 @@ * @contentstack/venus-pr-reviewers -* @contentstack/ghost-pr-reviewers .github/workflows/sca-scan.yml @contentstack/security-admin @@ -10,5 +9,3 @@ .github/workflows/policy-scan.yml @contentstack/security-admin .github/workflows/issues-jira.yml @contentstack/security-admin - -.github/workflows/codeql-anaylsis.yml @contentstack/security-admin From e3efc042646943e6e2235e74f3176ab1d1571791 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Fri, 23 Jan 2026 13:01:49 +0530 Subject: [PATCH 57/66] feat: indent support --- .talismanrc | 5 +++ LICENSE | 2 +- package-lock.json | 15 +++---- package.json | 2 +- src/fromRedactor.tsx | 15 +++++++ src/toRedactor.tsx | 10 +++++ test/fromRedactor.test.ts | 50 +++++++++++++++++++++++- test/toRedactor.test.ts | 82 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 171 insertions(+), 10 deletions(-) diff --git a/.talismanrc b/.talismanrc index baebc93..77ed317 100644 --- a/.talismanrc +++ b/.talismanrc @@ -28,3 +28,8 @@ fileignoreconfig: checksum: cccb3cd93c499acc87593eca5cc032e256c11cf530d4de67ece09e57fc430215 - filename: test/expectedJson.ts checksum: a1966b0b3993c8e3a0e9e45de49204e7788ba74ba0089a8a6b6eba0729f990bd +- filename: package-lock.json + checksum: 96da2dcdb517a744b09062fad7fbe38f49e4efe5535a3e9e65a94805e7e4808c +- filename: src/toRedactor.tsx + checksum: c6792b5b19cf89024ab33333a77d22af0375d4976c2fc64dae1d2f5971493397 +version: "1.0" \ No newline at end of file diff --git a/LICENSE b/LICENSE index 60c49c4..9dea702 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024-2025 Contentstack +Copyright (c) 2021-2025 Contentstack Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/package-lock.json b/package-lock.json index 4047ef4..375799e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "array-flat-polyfill": "^1.0.1", - "lodash": "^4.17.21", + "lodash": "^4.17.23", "lodash.clonedeep": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isempty": "^4.4.0", @@ -3836,9 +3836,10 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" }, "node_modules/lodash.clonedeep": { "version": "4.5.0", @@ -7947,9 +7948,9 @@ } }, "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" }, "lodash.clonedeep": { "version": "4.5.0", diff --git a/package.json b/package.json index d2e3a7d..a71effc 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ }, "dependencies": { "array-flat-polyfill": "^1.0.1", - "lodash": "^4.17.21", + "lodash": "^4.17.23", "lodash.clonedeep": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isempty": "^4.4.0", diff --git a/src/fromRedactor.tsx b/src/fromRedactor.tsx index 21c4808..0439144 100644 --- a/src/fromRedactor.tsx +++ b/src/fromRedactor.tsx @@ -452,8 +452,13 @@ export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject } if (el.style) { let allStyleAttrs: { [key: string]: any } = {} + const hasIndentLevel = redactor['data-indent-level'] Array.from({ length: el.style.length }).forEach((child, index) => { let property = el.style.item(index) + // If data-indent-level is present, skip margin-left as indent-level is source of truth + if (hasIndentLevel && kebabCase(property) === 'margin-left') { + return + } allStyleAttrs[kebabCase(property)] = el.style.getPropertyValue(property) }) elementAttrs = { @@ -476,6 +481,16 @@ export const fromRedactor = (el: any, options?:IHtmlToJsonOptions) : IAnyObject attrs: { ...elementAttrs['attrs'], 'data-sys-asset-uid': redactor['data-sys-asset-uid'] } } } + // If data-indent-level is present, keep it as a proper attribute (not in redactor-attributes) + // Also delete style from redactor to prevent margin-left from appearing in redactor-attributes + if (redactor['data-indent-level']) { + elementAttrs = { + ...elementAttrs, + attrs: { ...elementAttrs['attrs'], 'data-indent-level': redactor['data-indent-level'] } + } + delete redactor['data-indent-level'] + delete redactor['style'] + } elementAttrs = { ...elementAttrs, attrs: { ...elementAttrs['attrs'], "redactor-attributes": redactor } } } diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index 6ff92a0..1bfd4b5 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -211,10 +211,20 @@ export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string } if (allattrs['style'] && jsonValue['type'] !== 'img') { Object.keys(allattrs['style']).forEach((key) => { + // If data-indent-level is present, skip margin-left from style as indent-level is source of truth + if (allattrs['data-indent-level'] && kebbab(key) === 'margin-left') { + return + } style += `${kebbab(key)}: ${allattrs.style[key]};` }) delete allattrs['style'] } + if (allattrs['data-indent-level']) { + const indentLevel = Number(allattrs['data-indent-level']) + if (!isNaN(indentLevel) && indentLevel > 0) { + style += `margin-left: ${indentLevel * 30}px;` + } + } if (allattrs['rows'] && allattrs['cols'] && allattrs['colWidths']) { delete allattrs['rows'] delete allattrs['cols'] diff --git a/test/fromRedactor.test.ts b/test/fromRedactor.test.ts index 01b3f0e..b13ef08 100644 --- a/test/fromRedactor.test.ts +++ b/test/fromRedactor.test.ts @@ -421,10 +421,58 @@ describe("ELEMENT_TAGS", () => { }) }) -function htmlToJson (html: string, options: IHtmlToJsonOptions) { +function htmlToJson (html: string, options?: IHtmlToJsonOptions) { const dom = new JSDOM(html); let htmlDoc = dom.window.document.querySelector("body"); return fromRedactor(htmlDoc, options); } +describe("data-indent-level handling", () => { + test("should keep data-indent-level as a proper attribute, not in redactor-attributes", () => { + const html = `

Indented paragraph

` + const json = htmlToJson(html) + + expect(json.children[0].attrs['data-indent-level']).toBe('2') + expect(json.children[0].attrs['redactor-attributes']['data-indent-level']).toBeUndefined() + }) + + test("should not include margin-left in style when data-indent-level is present", () => { + const html = `

Indented paragraph

` + const json = htmlToJson(html) + + expect(json.children[0].attrs.style['margin-left']).toBeUndefined() + expect(json.children[0].attrs.style['color']).toBe('red') + expect(json.children[0].attrs['data-indent-level']).toBe('2') + }) + + test("should include margin-left in style when data-indent-level is NOT present", () => { + const html = `

Non-indented paragraph

` + const json = htmlToJson(html) + + expect(json.children[0].attrs.style['margin-left']).toBe('60px') + expect(json.children[0].attrs.style['color']).toBe('blue') + expect(json.children[0].attrs['data-indent-level']).toBeUndefined() + }) + + test("should not include style in redactor-attributes when data-indent-level is present", () => { + const html = `

Deeply indented

` + const json = htmlToJson(html) + + expect(json.children[0].attrs['redactor-attributes']['style']).toBeUndefined() + expect(json.children[0].attrs['data-indent-level']).toBe('3') + }) + + test("should handle data-indent-level with multiple style properties correctly", () => { + const html = `

Indented heading

` + const json = htmlToJson(html) + + expect(json.children[0].attrs['data-indent-level']).toBe('1') + expect(json.children[0].attrs.style['margin-left']).toBeUndefined() + expect(json.children[0].attrs.style['text-align']).toBe('center') + expect(json.children[0].attrs.style['font-size']).toBe('24px') + expect(json.children[0].attrs['redactor-attributes']['data-indent-level']).toBeUndefined() + expect(json.children[0].attrs['redactor-attributes']['style']).toBeUndefined() + }) +}) + diff --git a/test/toRedactor.test.ts b/test/toRedactor.test.ts index 97997c7..ef9cbf1 100644 --- a/test/toRedactor.test.ts +++ b/test/toRedactor.test.ts @@ -353,3 +353,85 @@ test("should convert codeblock to proper html, where \n should not be replaced w const html = toRedactor(json); expect(html).toBe(`
Hi\nHello
`); }) + +describe("data-indent-level handling", () => { + test("should generate margin-left based on data-indent-level value", () => { + const json = { + "type": "doc", + "attrs": {}, + "children": [{ + "type": "p", + "attrs": { "data-indent-level": "2" }, + "children": [{ "text": "Indented paragraph" }] + }] + } + const html = toRedactor(json) + expect(html).toBe('

Indented paragraph

') + }) + + test("should use data-indent-level instead of margin-left from style when both are present", () => { + const json = { + "type": "doc", + "attrs": {}, + "children": [{ + "type": "p", + "attrs": { + "data-indent-level": "3", + "style": { "margin-left": "100px", "color": "red" } + }, + "children": [{ "text": "Indented paragraph" }] + }] + } + const html = toRedactor(json) + // margin-left should be 90px (3 * 30px) not 100px, and color should be preserved + expect(html).toBe('

Indented paragraph

') + }) + + test("should preserve margin-left from style when data-indent-level is NOT present", () => { + const json = { + "type": "doc", + "attrs": {}, + "children": [{ + "type": "p", + "attrs": { + "style": { "margin-left": "100px" } + }, + "children": [{ "text": "Non-indented paragraph" }] + }] + } + const html = toRedactor(json) + expect(html).toBe('

Non-indented paragraph

') + }) + + test("should not add margin-left when data-indent-level is 0", () => { + const json = { + "type": "doc", + "attrs": {}, + "children": [{ + "type": "p", + "attrs": { "data-indent-level": "0" }, + "children": [{ "text": "No indent" }] + }] + } + const html = toRedactor(json) + expect(html).toBe('

No indent

') + }) + + test("should handle data-indent-level with multiple style properties correctly", () => { + const json = { + "type": "doc", + "attrs": {}, + "children": [{ + "type": "h2", + "attrs": { + "data-indent-level": "1", + "style": { "margin-left": "50px", "text-align": "center", "font-size": "24px" } + }, + "children": [{ "text": "Indented heading" }] + }] + } + const html = toRedactor(json) + // margin-left should be 30px (1 * 30px), other styles should be preserved + expect(html).toBe('

Indented heading

') + }) +}) From ecca7c9981130823a0b825837962bfd9b5494b72 Mon Sep 17 00:00:00 2001 From: Shreya Kamble Date: Fri, 23 Jan 2026 13:12:16 +0530 Subject: [PATCH 58/66] chore: update licence --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 9dea702..ecd01a2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2025 Contentstack +Copyright (c) 2021-2026 Contentstack Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From d957b023226eeec60fd8e084e9193b3e2b833a1e Mon Sep 17 00:00:00 2001 From: shreya-kamble <92981124+shreya-kamble@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:46:00 +0530 Subject: [PATCH 59/66] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a71effc..721cc6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@contentstack/json-rte-serializer", - "version": "3.0.4", + "version": "3.0.5", "description": "This Package converts Html Document to Json and vice-versa.", "main": "lib/index.js", "module": "lib/index.mjs", From 945d23cda5609bd3a1c9b2b0660e805a40311f5e Mon Sep 17 00:00:00 2001 From: dhaval Date: Tue, 3 Mar 2026 13:46:57 +0530 Subject: [PATCH 60/66] Update sca-scan.yml --- .github/workflows/sca-scan.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/sca-scan.yml b/.github/workflows/sca-scan.yml index f09161f..2307d48 100644 --- a/.github/workflows/sca-scan.yml +++ b/.github/workflows/sca-scan.yml @@ -13,3 +13,6 @@ jobs: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} with: args: --all-projects --fail-on=all + json: true + continue-on-error: true + - uses: contentstack/sca-policy@main From 44da5fa0ceaddfb8e95a25c8dc2310f099fbe63e Mon Sep 17 00:00:00 2001 From: Jeremy John Date: Thu, 7 May 2026 09:47:44 -0500 Subject: [PATCH 61/66] feat: add jsonToHtmlAsync for async customElementTypes support Add toRedactorAsync (exported as jsonToHtmlAsync) that supports customElementTypes handlers returning string | Promise. Enables dynamic component resolution (e.g. await import()) before serialization. Children are resolved via Promise.all concurrently. Refactors shared logic (text processing, attr building, element node processing) into toRedactorHelpers.ts so both sync and async versions are thin recursive shells with no duplicated code. The existing sync jsonToHtml behavior is unchanged. New types: IJsonToHtmlAsyncElementTags, IJsonToHtmlAsyncOptions. --- src/index.tsx | 3 +- src/toRedactor.tsx | 429 +++-------------------------------- src/toRedactorAsync.tsx | 42 ++++ src/toRedactorHelpers.ts | 410 +++++++++++++++++++++++++++++++++ src/types.ts | 8 + test/toRedactorAsync.test.ts | 257 +++++++++++++++++++++ 6 files changed, 747 insertions(+), 402 deletions(-) create mode 100644 src/toRedactorAsync.tsx create mode 100644 src/toRedactorHelpers.ts create mode 100644 test/toRedactorAsync.test.ts diff --git a/src/index.tsx b/src/index.tsx index d1e48ff..c90be0c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,7 @@ import "array-flat-polyfill" import { fromRedactor } from "./fromRedactor" import { toRedactor } from "./toRedactor" +import { toRedactorAsync } from "./toRedactorAsync" import {jsonToMarkdownSerializer} from './jsonToMarkdown' export * from "./types" -export { fromRedactor as htmlToJson, toRedactor as jsonToHtml, jsonToMarkdownSerializer as jsonToMarkdown } \ No newline at end of file +export { fromRedactor as htmlToJson, toRedactor as jsonToHtml, toRedactorAsync as jsonToHtmlAsync, jsonToMarkdownSerializer as jsonToMarkdown } \ No newline at end of file diff --git a/src/toRedactor.tsx b/src/toRedactor.tsx index 1bfd4b5..9da3ecd 100644 --- a/src/toRedactor.tsx +++ b/src/toRedactor.tsx @@ -1,415 +1,42 @@ -import kebbab from 'lodash.kebabcase' -import isEmpty from 'lodash.isempty' -import {IJsonToHtmlElementTags, IJsonToHtmlOptions, IJsonToHtmlTextTags, IJsonToHtmlAllowedEmptyAttributes} from './types' -import isPlainObject from 'lodash.isplainobject' -import {replaceHtmlEntities, forbiddenAttrChars } from './utils' -import { HTML_ELEMENT_TYPES, HTML_TEXT_WRAPPERS, ALLOWED_EMPTY_ATTRIBUTES } from './constants' +import {IJsonToHtmlOptions} from './types' +import { initLocals, ILocals, processTextNode, processNonStandardType, processElementNode } from './toRedactorHelpers' -let ADD_NBSP_FOR_EMPTY_BLOCKS : boolean = false +export const toRedactor = (jsonValue: any, options?: IJsonToHtmlOptions): string => { + const locals = initLocals(options) + return _toRedactor(jsonValue, options, locals) +} + +function _toRedactor(jsonValue: any, options: IJsonToHtmlOptions | undefined, locals: ILocals): string { + const { localTextWrappers, localElementTypes, localAllowedEmptyAttributes, addNbspForEmptyBlocks } = locals -export const toRedactor = (jsonValue: any,options?:IJsonToHtmlOptions) : string => { - //TODO: optimize assign once per function call - if(options?.addNbspForEmptyBlocks){ - ADD_NBSP_FOR_EMPTY_BLOCKS = options?.addNbspForEmptyBlocks - } - let localTextWrappers: IJsonToHtmlTextTags = HTML_TEXT_WRAPPERS; - let localAllowedEmptyAttributes: IJsonToHtmlAllowedEmptyAttributes = ALLOWED_EMPTY_ATTRIBUTES; - let localElementTypes: IJsonToHtmlElementTags = HTML_ELEMENT_TYPES; - - if(options?.customTextWrapper && !isEmpty(options.customTextWrapper)){ - localTextWrappers = { ...localTextWrappers, ...options.customTextWrapper }; - } - if (options?.allowedEmptyAttributes && !isEmpty(options.allowedEmptyAttributes)) { - localAllowedEmptyAttributes = { ...ALLOWED_EMPTY_ATTRIBUTES }; - Object.keys(options.allowedEmptyAttributes).forEach(key => { - localAllowedEmptyAttributes[key] = [ - ...(localAllowedEmptyAttributes[key] ?? []), - ...(options.allowedEmptyAttributes?.[key] || []) - ]; - }); - } if (jsonValue.hasOwnProperty('text')) { - let text = jsonValue['text'].replace(//g, '>') - if (jsonValue['break']) { - text = text.replace(/\n/g, '
') - } - if(jsonValue['classname'] || jsonValue['id']){ - if(jsonValue['classname'] && jsonValue['id']){ - text = `${text}` - } - else if(jsonValue['classname'] && !jsonValue['id']){ - text = `${text}` - } - else if(jsonValue['id'] && !jsonValue['classname']){ - text = `${text}` - } - } - if (jsonValue.text.includes('\n') && !jsonValue['break']) { - text = text.replace(/\n/g, '
') - } - Object.entries(jsonValue).forEach(([key, value]) => { - if(localTextWrappers.hasOwnProperty(key)){ - text = localTextWrappers[key](text,value) - } - }) - if (jsonValue['attrs']) { - const { style } = jsonValue['attrs'] - if (style) { - let attrsStyle = '' - if (style.color) { - attrsStyle = `color:${style.color};` - } - if (style["font-family"]) { - attrsStyle += `font-family:"${style.fontFamily}";` - } - if (style["font-size"]) { - attrsStyle += `font-size: ${style.fontSize};` - } - if (attrsStyle !== '') { - text = `${text}` - } - } - } - return text - } - let children: any = '' - if (options?.customElementTypes && !isEmpty(options.customElementTypes)) { - localElementTypes = { ...localElementTypes, ...options.customElementTypes }; + return processTextNode(jsonValue, localTextWrappers) } + + let children: string = '' if (jsonValue.children) { - children = Array.from(jsonValue.children).map((child) => toRedactor(child,options)) + let mapped = Array.from(jsonValue.children).map((child) => _toRedactor(child, options, locals)) if (jsonValue['type'] === 'blockquote') { - children = children.map((child: any) => { - if (child === '\n') { - return '
' - } - return child - }) + mapped = mapped.map((child: any) => child === '\n' ? '
' : child) } - children = children.join('') + children = mapped.join('') } - if (options?.allowNonStandardTypes && !Object.keys(localElementTypes).includes(jsonValue['type']) && jsonValue['type'] !== 'doc') { - let attrs = '' - Object.entries(jsonValue?.attrs|| {}).forEach(([key, val]) => { - if(isPlainObject(val)){ - val = JSON.stringify(val) - attrs += ` ${key}='${val}'` - } - else{ - attrs += val ? ` ${key}="${val}"` : ` ${key}`; - } - }) - attrs = (attrs.trim() ? ' ' : '') + attrs.trim() - console.warn(`${jsonValue['type']} is not a valid element type.`) - return `<${jsonValue['type'].toLowerCase()}${attrs}>${children}` - } - if (localElementTypes[jsonValue['type']]) { - let attrs = '' - let orgType - let figureStyles: any = { - fieldsEdited: [] - } - if (jsonValue.attrs) { - let attrsJson: { [key: string]: any } = {} - let allattrs = JSON.parse(JSON.stringify(jsonValue.attrs)) - let style = '' - if (jsonValue.attrs["redactor-attributes"]) { - attrsJson = { ...allattrs["redactor-attributes"] } - } - if (jsonValue['type'] === 'reference' && jsonValue?.attrs?.default) { - orgType = "img" - let inline = '' - if (attrsJson['asset-link']) { - attrsJson['src'] = attrsJson['asset-link'] - delete attrsJson['asset-link'] - delete allattrs['asset-link'] - } - if (attrsJson['inline']) { - inline = `display: flow-root;margin:0` - delete attrsJson['width'] - delete attrsJson['style'] - } - if (attrsJson['position']) { - figureStyles.position = - attrsJson['position'] === 'center' - ? `style = "margin: auto; text-align: center;width: ${allattrs['width'] ? allattrs['width'] + '%' : 100 + '%' - };"` - : `style = "float: ${attrsJson['position']};${inline};width: ${allattrs['width'] ? allattrs['width'] + '%' : 100 + '%' - };max-width:${allattrs['max-width'] ? allattrs['max-width'] + '%' : 100 + '%'};"` - figureStyles.alignment = attrsJson['position'] - figureStyles.fieldsEdited.push(figureStyles.position) - delete attrsJson['position'] - attrsJson['width'] && delete attrsJson['width'] - attrsJson['style'] && delete attrsJson['style'] - attrsJson['height'] && delete attrsJson['height'] - attrsJson['max-width'] && delete attrsJson['max-width'] - allattrs['max-width'] && delete allattrs['max-width'] - allattrs['width'] && delete allattrs['width'] - if (allattrs["redactor-attributes"]) { - allattrs["redactor-attributes"]['width'] && delete allattrs["redactor-attributes"]['width'] - allattrs?.["redactor-attributes"]?.['style'] && delete allattrs["redactor-attributes"]['style'] - allattrs?.["redactor-attributes"]?.['max-width'] && delete allattrs["redactor-attributes"]['max-width'] - } - } - if (attrsJson['asset-caption']) { - figureStyles.caption = attrsJson['asset-caption'] - figureStyles.fieldsEdited.push(figureStyles.caption) - delete attrsJson['asset-caption'] - delete allattrs['asset-caption'] - } - if (attrsJson['link']) { - let anchor = '' - anchor = `href="${attrsJson['link']}"` - if (attrsJson['target']) { - anchor += ' target="_blank"' - } - figureStyles.anchorLink = `${anchor}` - figureStyles.fieldsEdited.push(figureStyles.anchorLink) - delete attrsJson['link'] - delete allattrs['link'] - } - delete allattrs['default'] - delete attrsJson['default'] - delete attrsJson['target'] - delete allattrs['asset-link'] - delete allattrs['asset-type'] - delete allattrs['display-type'] - - } - if (jsonValue['type'] === 'a') { - attrsJson['href'] = allattrs['url'] - } - if (allattrs['orgType']) { - orgType = allattrs['orgType'] - delete allattrs['orgType'] - } - if (allattrs['class-name']) { - attrsJson['class'] = allattrs['class-name'] - delete allattrs['class-name'] - } - if (attrsJson['width']) { - let width = attrsJson['width'] - if(typeof width === 'number'){ - width = width.toString() - } - if (width.slice(width.length - 1) === '%') { - style = `width: ${allattrs['width'] + '%'}; height: ${attrsJson['height'] ? attrsJson['height'] : 'auto'};` - } else { - style = `width: ${allattrs['width']}; height: ${attrsJson['height'] ? attrsJson['height'] : 'auto'};` - } - } else { - if (allattrs['width']) { - let width = String(allattrs['width']) - - if (width.slice(width.length - 1) === '%') { - allattrs['width'] = allattrs['width'] + '%' - } else { - allattrs['width'] = String(allattrs['width']) - } - // style = `width: ${allattrs['width']}; height: auto;` - } - } - if (allattrs['style'] && jsonValue['type'] !== 'img') { - Object.keys(allattrs['style']).forEach((key) => { - // If data-indent-level is present, skip margin-left from style as indent-level is source of truth - if (allattrs['data-indent-level'] && kebbab(key) === 'margin-left') { - return - } - style += `${kebbab(key)}: ${allattrs.style[key]};` - }) - delete allattrs['style'] - } - if (allattrs['data-indent-level']) { - const indentLevel = Number(allattrs['data-indent-level']) - if (!isNaN(indentLevel) && indentLevel > 0) { - style += `margin-left: ${indentLevel * 30}px;` - } - } - if (allattrs['rows'] && allattrs['cols'] && allattrs['colWidths']) { - delete allattrs['rows'] - delete allattrs['cols'] - delete allattrs['colWidths'] - } - if (allattrs['disabledCols']) { - delete allattrs['disabledCols'] - } - if (allattrs['colSpan']) { - delete allattrs['colSpan'] - } - if (allattrs['rowSpan']) { - delete allattrs['rowSpan'] - } - - attrsJson = { ...attrsJson, ...allattrs, style: style } - if (jsonValue['type'] === 'reference') { - if (attrsJson['type'] === "entry") { - attrsJson['data-sys-entry-uid'] = allattrs['entry-uid'] - delete attrsJson['entry-uid'] - attrsJson['data-sys-entry-locale'] = allattrs['locale'] - delete attrsJson['locale'] - attrsJson['data-sys-content-type-uid'] = allattrs['content-type-uid'] - delete attrsJson['content-type-uid'] - attrsJson['sys-style-type'] = allattrs['display-type'] - delete attrsJson['display-type'] - } - - else if (attrsJson['type'] === "asset") { - attrsJson['data-sys-asset-filelink'] = allattrs['asset-link'] - delete attrsJson['asset-link'] - attrsJson['data-sys-asset-uid'] = allattrs['asset-uid'] - delete attrsJson['asset-uid'] - attrsJson['data-sys-asset-filename'] = allattrs['asset-name'] - delete attrsJson['asset-name'] - attrsJson['data-sys-asset-contenttype'] = allattrs['asset-type'] - delete attrsJson['asset-type'] - // - if (allattrs['asset-caption']) { - attrsJson['data-sys-asset-caption'] = allattrs['asset-caption'] - delete attrsJson['asset-caption'] - } - - if (allattrs['asset-alt']) { - attrsJson['data-sys-asset-alt'] = allattrs['asset-alt'] - delete attrsJson['aasset-alt'] - } - - if (allattrs['link']) { - attrsJson['data-sys-asset-link'] = allattrs['link'] - delete attrsJson['link'] - } - - if (allattrs['position']) { - attrsJson['data-sys-asset-position'] = allattrs['position'] - delete attrsJson['position'] - } - - if (allattrs['target']) { - attrsJson['data-sys-asset-isnewtab'] = allattrs['target'] === "_blank" - delete attrsJson['target'] - } - if (!attrsJson['sys-style-type']) { - attrsJson['sys-style-type'] = String(allattrs['asset-type']).indexOf('image') > -1 ? 'display' : 'download' - } - if (attrsJson?.["display-type"] === "display") { - const styleObj = jsonValue?.["attrs"]?.["style"] ?? {}; - if (!styleObj["width"]) { - styleObj["width"] = "auto"; - } - delete styleObj["float"]; - // (attrsJson["style"] && typeof attrsJson["style"] === 'string') - // ? (attrsJson["style"] += getStyleStringFromObject(styleObj)) : - (attrsJson["style"] = getStyleStringFromObject(styleObj)); - } - delete attrsJson['display-type'] - } - } - if (jsonValue['type'] === "style") { - delete attrsJson['style-text'] - } - if(jsonValue['type'] === 'img'){ - attrsJson['src'] = allattrs['url'] - - if(allattrs['caption']) figureStyles.caption = allattrs['caption'] - if(allattrs['position']) figureStyles.position = allattrs['position'] - if(allattrs['anchorLink']) figureStyles.anchorLink = `href="${allattrs['anchorLink']}"` - if(allattrs['target']){ - figureStyles.anchorLink += ` target="${allattrs['target']}"` - } - figureStyles.fieldsEdited.push(figureStyles.caption) - } - - if (jsonValue['type'] === 'social-embeds' || jsonValue['type'] === 'embed') { - attrsJson['src'] = encodeURI(allattrs['src']); - } - - if(!(options?.customElementTypes && !isEmpty(options.customElementTypes) && options.customElementTypes[jsonValue['type']])) { - delete attrsJson['url'] - } - delete attrsJson['redactor-attributes'] - Object.entries(attrsJson).forEach((item) => { - if (forbiddenAttrChars.some(char => item[0].includes(char))) { - return; - } + const nonStandard = processNonStandardType(jsonValue, children, localElementTypes, options?.allowNonStandardTypes) + if (nonStandard !== null) return nonStandard - if (localAllowedEmptyAttributes.hasOwnProperty(jsonValue['type']) && localAllowedEmptyAttributes[jsonValue['type']].includes(item[0])) { - attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" `; - return; - } - return item[1] ? (item[1] !== '' ? (attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" `) : '') : '' - }) - - attrs = (attrs.trim() ? ' ' : '') + attrs.trim() - } - if (jsonValue['type'] === 'table') { - let colWidths = jsonValue.attrs.colWidths - let totalWidth = colWidths.reduce((a: any, b: any) => a + b, 0) - var setCol = new Set(colWidths) - if (!(setCol.size === 1 && jsonValue.attrs.cols * setCol.values().next().value === totalWidth)) { - let col = '' - Array.from(colWidths).forEach( - (colWidth, index) => { - const width = (colWidth as number / totalWidth) * 100 - col += `` - } - ) - let colgroup = `${col}` - children = colgroup + children - } - } - if (jsonValue['type'] === 'check-list') { - attrs = `data-checked='${jsonValue.checked}' data-type='checked'` - } - - if (jsonValue['type'] === 'row') { - attrs = `data-type='row' style="max-width:100%;display:flex;"` - } - - if (jsonValue['type'] === 'column') { - const { width } = jsonValue?.meta || {} - attrs = `data-type='column' width="${width}" style="flex-grow: 0;flex-shrink: 0;position: relative;width:${width * 100 - }%; margin: 0 0.25rem;"` - } - - if (jsonValue['type'] === 'grid-container') { - const { gutter } = jsonValue.attrs - attrs = `data-type='grid-container' gutter="${gutter}" style="display: flex; width: 100%; gap: ${gutter}px"` - } - - if (jsonValue['type'] === 'grid-child') { - const { gridRatio } = jsonValue.attrs - attrs = `data-type='grid-child' grid-ratio="${gridRatio}" style="flex: ${gridRatio}"` - } - - if (jsonValue['type'] === 'reference') { - figureStyles.displayType = jsonValue?.attrs?.["display-type"] - } - - if (jsonValue['type'] === 'span' && jsonValue.children.length === 1 && jsonValue.children[0].type === 'span') { - if (Object.keys(jsonValue.attrs).length === 0) { - return children - } - } - - if(['td','th'].includes(jsonValue['type'])){ - if(jsonValue?.['attrs']?.['void']) return '' - } - - attrs = (attrs.trim() ? ' ' : '') + attrs.trim() + if (localElementTypes[jsonValue['type']]) { + const result = processElementNode(jsonValue, children, options, localElementTypes, localAllowedEmptyAttributes) + if ('earlyReturn' in result) return result.earlyReturn - return localElementTypes[orgType || jsonValue['type']]( - attrs, - ADD_NBSP_FOR_EMPTY_BLOCKS && !children ? ' ' : children, - jsonValue, - figureStyles - ) + const { attrs, orgType, figureStyles, children: finalChildren } = result + return localElementTypes[orgType || jsonValue['type']]( + attrs, + addNbspForEmptyBlocks && !finalChildren ? ' ' : finalChildren, + jsonValue, + figureStyles, + ) as string } + return children } - - -function getStyleStringFromObject(styleObj: { [key: string]: string }) { - return Object.keys(styleObj) - .map((key) => `${key}: ${styleObj[key]}`) - .join("; "); -} \ No newline at end of file diff --git a/src/toRedactorAsync.tsx b/src/toRedactorAsync.tsx new file mode 100644 index 0000000..f952a6f --- /dev/null +++ b/src/toRedactorAsync.tsx @@ -0,0 +1,42 @@ +import {IJsonToHtmlAsyncOptions} from './types' +import { initLocals, ILocals, processTextNode, processNonStandardType, processElementNode } from './toRedactorHelpers' + +export const toRedactorAsync = async (jsonValue: any, options?: IJsonToHtmlAsyncOptions): Promise => { + const locals = initLocals(options) + return _toRedactorAsync(jsonValue, options, locals) +} + +async function _toRedactorAsync(jsonValue: any, options: IJsonToHtmlAsyncOptions | undefined, locals: ILocals): Promise { + const { localTextWrappers, localElementTypes, localAllowedEmptyAttributes, addNbspForEmptyBlocks } = locals + + if (jsonValue.hasOwnProperty('text')) { + return processTextNode(jsonValue, localTextWrappers) + } + + let children: string = '' + if (jsonValue.children) { + let mapped = await Promise.all(Array.from(jsonValue.children).map((child) => _toRedactorAsync(child, options, locals))) + if (jsonValue['type'] === 'blockquote') { + mapped = mapped.map((child: any) => child === '\n' ? '
' : child) + } + children = mapped.join('') + } + + const nonStandard = processNonStandardType(jsonValue, children, localElementTypes, options?.allowNonStandardTypes) + if (nonStandard !== null) return nonStandard + + if (localElementTypes[jsonValue['type']]) { + const result = processElementNode(jsonValue, children, options, localElementTypes, localAllowedEmptyAttributes) + if ('earlyReturn' in result) return result.earlyReturn + + const { attrs, orgType, figureStyles, children: finalChildren } = result + return await localElementTypes[orgType || jsonValue['type']]( + attrs, + addNbspForEmptyBlocks && !finalChildren ? ' ' : finalChildren, + jsonValue, + figureStyles, + ) + } + + return children +} diff --git a/src/toRedactorHelpers.ts b/src/toRedactorHelpers.ts new file mode 100644 index 0000000..4049e0c --- /dev/null +++ b/src/toRedactorHelpers.ts @@ -0,0 +1,410 @@ +import kebbab from 'lodash.kebabcase' +import isEmpty from 'lodash.isempty' +import {IJsonToHtmlTextTags, IJsonToHtmlAllowedEmptyAttributes, IJsonToHtmlAsyncElementTags} from './types' +import isPlainObject from 'lodash.isplainobject' +import {replaceHtmlEntities, forbiddenAttrChars } from './utils' +import { HTML_ELEMENT_TYPES, HTML_TEXT_WRAPPERS, ALLOWED_EMPTY_ATTRIBUTES } from './constants' + +export interface ILocals { + localTextWrappers: IJsonToHtmlTextTags + localElementTypes: IJsonToHtmlAsyncElementTags + localAllowedEmptyAttributes: IJsonToHtmlAllowedEmptyAttributes + addNbspForEmptyBlocks: boolean +} + +/** Set up local handler maps from options. Shared by sync and async paths. */ +export function initLocals(options?: { + customElementTypes?: IJsonToHtmlAsyncElementTags + customTextWrapper?: IJsonToHtmlTextTags + allowedEmptyAttributes?: IJsonToHtmlAllowedEmptyAttributes + addNbspForEmptyBlocks?: boolean +}): ILocals { + let localTextWrappers: IJsonToHtmlTextTags = HTML_TEXT_WRAPPERS + let localAllowedEmptyAttributes: IJsonToHtmlAllowedEmptyAttributes = ALLOWED_EMPTY_ATTRIBUTES + let localElementTypes: IJsonToHtmlAsyncElementTags = HTML_ELEMENT_TYPES + + if (options?.customTextWrapper && !isEmpty(options.customTextWrapper)) { + localTextWrappers = { ...localTextWrappers, ...options.customTextWrapper } + } + if (options?.allowedEmptyAttributes && !isEmpty(options.allowedEmptyAttributes)) { + localAllowedEmptyAttributes = { ...ALLOWED_EMPTY_ATTRIBUTES } + Object.keys(options.allowedEmptyAttributes).forEach(key => { + localAllowedEmptyAttributes[key] = [ + ...(localAllowedEmptyAttributes[key] ?? []), + ...(options.allowedEmptyAttributes?.[key] || []) + ] + }) + } + if (options?.customElementTypes && !isEmpty(options.customElementTypes)) { + localElementTypes = { ...localElementTypes, ...options.customElementTypes } + } + + return { + localTextWrappers, + localElementTypes, + localAllowedEmptyAttributes, + addNbspForEmptyBlocks: options?.addNbspForEmptyBlocks ?? false, + } +} + +/** Process a text leaf node. Returns the serialized HTML string. */ +export function processTextNode(jsonValue: any, localTextWrappers: IJsonToHtmlTextTags): string { + let text = jsonValue['text'].replace(//g, '>') + if (jsonValue['break']) { + text = text.replace(/\n/g, '
') + } + if (jsonValue['classname'] || jsonValue['id']) { + if (jsonValue['classname'] && jsonValue['id']) { + text = `${text}` + } else if (jsonValue['classname'] && !jsonValue['id']) { + text = `${text}` + } else if (jsonValue['id'] && !jsonValue['classname']) { + text = `${text}` + } + } + if (jsonValue.text.includes('\n') && !jsonValue['break']) { + text = text.replace(/\n/g, '
') + } + Object.entries(jsonValue).forEach(([key, value]) => { + if (localTextWrappers.hasOwnProperty(key)) { + text = localTextWrappers[key](text, value) + } + }) + if (jsonValue['attrs']) { + const { style } = jsonValue['attrs'] + if (style) { + let attrsStyle = '' + if (style.color) { + attrsStyle = `color:${style.color};` + } + if (style["font-family"]) { + attrsStyle += `font-family:"${style.fontFamily}";` + } + if (style["font-size"]) { + attrsStyle += `font-size: ${style.fontSize};` + } + if (attrsStyle !== '') { + text = `${text}` + } + } + } + return text +} + +/** Handle allowNonStandardTypes fallback. Returns HTML string or null if not applicable. */ +export function processNonStandardType( + jsonValue: any, + children: string, + localElementTypes: IJsonToHtmlAsyncElementTags, + allowNonStandardTypes?: boolean, +): string | null { + if (!allowNonStandardTypes || Object.keys(localElementTypes).includes(jsonValue['type']) || jsonValue['type'] === 'doc') { + return null + } + let attrs = '' + Object.entries(jsonValue?.attrs || {}).forEach(([key, val]) => { + if (isPlainObject(val)) { + val = JSON.stringify(val) + attrs += ` ${key}='${val}'` + } else { + attrs += val ? ` ${key}="${val}"` : ` ${key}` + } + }) + attrs = (attrs.trim() ? ' ' : '') + attrs.trim() + console.warn(`${jsonValue['type']} is not a valid element type.`) + return `<${jsonValue['type'].toLowerCase()}${attrs}>${children}` +} + +export type ElementProcessResult = + | { earlyReturn: string } + | { attrs: string; orgType: string | undefined; figureStyles: any; children: string } + +/** + * Process an element node's attrs, type-specific logic, and children modifications. + * Returns either an early return value or the computed attrs/orgType/figureStyles + * needed for the final handler call. + */ +export function processElementNode( + jsonValue: any, + children: string, + options: { customElementTypes?: IJsonToHtmlAsyncElementTags } | undefined, + localElementTypes: IJsonToHtmlAsyncElementTags, + localAllowedEmptyAttributes: IJsonToHtmlAllowedEmptyAttributes, +): ElementProcessResult { + let attrs = '' + let orgType: string | undefined + let figureStyles: any = { fieldsEdited: [] } + + if (jsonValue.attrs) { + let attrsJson: { [key: string]: any } = {} + let allattrs = JSON.parse(JSON.stringify(jsonValue.attrs)) + let style = '' + if (jsonValue.attrs["redactor-attributes"]) { + attrsJson = { ...allattrs["redactor-attributes"] } + } + if (jsonValue['type'] === 'reference' && jsonValue?.attrs?.default) { + orgType = "img" + let inline = '' + if (attrsJson['asset-link']) { + attrsJson['src'] = attrsJson['asset-link'] + delete attrsJson['asset-link'] + delete allattrs['asset-link'] + } + if (attrsJson['inline']) { + inline = `display: flow-root;margin:0` + delete attrsJson['width'] + delete attrsJson['style'] + } + if (attrsJson['position']) { + figureStyles.position = + attrsJson['position'] === 'center' + ? `style = "margin: auto; text-align: center;width: ${allattrs['width'] ? allattrs['width'] + '%' : 100 + '%' + };"` + : `style = "float: ${attrsJson['position']};${inline};width: ${allattrs['width'] ? allattrs['width'] + '%' : 100 + '%' + };max-width:${allattrs['max-width'] ? allattrs['max-width'] + '%' : 100 + '%'};"` + figureStyles.alignment = attrsJson['position'] + figureStyles.fieldsEdited.push(figureStyles.position) + delete attrsJson['position'] + attrsJson['width'] && delete attrsJson['width'] + attrsJson['style'] && delete attrsJson['style'] + attrsJson['height'] && delete attrsJson['height'] + attrsJson['max-width'] && delete attrsJson['max-width'] + allattrs['max-width'] && delete allattrs['max-width'] + allattrs['width'] && delete allattrs['width'] + if (allattrs["redactor-attributes"]) { + allattrs["redactor-attributes"]['width'] && delete allattrs["redactor-attributes"]['width'] + allattrs?.["redactor-attributes"]?.['style'] && delete allattrs["redactor-attributes"]['style'] + allattrs?.["redactor-attributes"]?.['max-width'] && delete allattrs["redactor-attributes"]['max-width'] + } + } + if (attrsJson['asset-caption']) { + figureStyles.caption = attrsJson['asset-caption'] + figureStyles.fieldsEdited.push(figureStyles.caption) + delete attrsJson['asset-caption'] + delete allattrs['asset-caption'] + } + if (attrsJson['link']) { + let anchor = '' + anchor = `href="${attrsJson['link']}"` + if (attrsJson['target']) { + anchor += ' target="_blank"' + } + figureStyles.anchorLink = `${anchor}` + figureStyles.fieldsEdited.push(figureStyles.anchorLink) + delete attrsJson['link'] + delete allattrs['link'] + } + delete allattrs['default'] + delete attrsJson['default'] + delete attrsJson['target'] + delete allattrs['asset-link'] + delete allattrs['asset-type'] + delete allattrs['display-type'] + } + if (jsonValue['type'] === 'a') { + attrsJson['href'] = allattrs['url'] + } + if (allattrs['orgType']) { + orgType = allattrs['orgType'] + delete allattrs['orgType'] + } + if (allattrs['class-name']) { + attrsJson['class'] = allattrs['class-name'] + delete allattrs['class-name'] + } + if (attrsJson['width']) { + let width = attrsJson['width'] + if (typeof width === 'number') { + width = width.toString() + } + if (width.slice(width.length - 1) === '%') { + style = `width: ${allattrs['width'] + '%'}; height: ${attrsJson['height'] ? attrsJson['height'] : 'auto'};` + } else { + style = `width: ${allattrs['width']}; height: ${attrsJson['height'] ? attrsJson['height'] : 'auto'};` + } + } else { + if (allattrs['width']) { + let width = String(allattrs['width']) + if (width.slice(width.length - 1) === '%') { + allattrs['width'] = allattrs['width'] + '%' + } else { + allattrs['width'] = String(allattrs['width']) + } + } + } + if (allattrs['style'] && jsonValue['type'] !== 'img') { + Object.keys(allattrs['style']).forEach((key) => { + if (allattrs['data-indent-level'] && kebbab(key) === 'margin-left') { + return + } + style += `${kebbab(key)}: ${allattrs.style[key]};` + }) + delete allattrs['style'] + } + if (allattrs['data-indent-level']) { + const indentLevel = Number(allattrs['data-indent-level']) + if (!isNaN(indentLevel) && indentLevel > 0) { + style += `margin-left: ${indentLevel * 30}px;` + } + } + if (allattrs['rows'] && allattrs['cols'] && allattrs['colWidths']) { + delete allattrs['rows'] + delete allattrs['cols'] + delete allattrs['colWidths'] + } + if (allattrs['disabledCols']) { + delete allattrs['disabledCols'] + } + if (allattrs['colSpan']) { + delete allattrs['colSpan'] + } + if (allattrs['rowSpan']) { + delete allattrs['rowSpan'] + } + + attrsJson = { ...attrsJson, ...allattrs, style: style } + if (jsonValue['type'] === 'reference') { + if (attrsJson['type'] === "entry") { + attrsJson['data-sys-entry-uid'] = allattrs['entry-uid'] + delete attrsJson['entry-uid'] + attrsJson['data-sys-entry-locale'] = allattrs['locale'] + delete attrsJson['locale'] + attrsJson['data-sys-content-type-uid'] = allattrs['content-type-uid'] + delete attrsJson['content-type-uid'] + attrsJson['sys-style-type'] = allattrs['display-type'] + delete attrsJson['display-type'] + } else if (attrsJson['type'] === "asset") { + attrsJson['data-sys-asset-filelink'] = allattrs['asset-link'] + delete attrsJson['asset-link'] + attrsJson['data-sys-asset-uid'] = allattrs['asset-uid'] + delete attrsJson['asset-uid'] + attrsJson['data-sys-asset-filename'] = allattrs['asset-name'] + delete attrsJson['asset-name'] + attrsJson['data-sys-asset-contenttype'] = allattrs['asset-type'] + delete attrsJson['asset-type'] + if (allattrs['asset-caption']) { + attrsJson['data-sys-asset-caption'] = allattrs['asset-caption'] + delete attrsJson['asset-caption'] + } + if (allattrs['asset-alt']) { + attrsJson['data-sys-asset-alt'] = allattrs['asset-alt'] + delete attrsJson['aasset-alt'] + } + if (allattrs['link']) { + attrsJson['data-sys-asset-link'] = allattrs['link'] + delete attrsJson['link'] + } + if (allattrs['position']) { + attrsJson['data-sys-asset-position'] = allattrs['position'] + delete attrsJson['position'] + } + if (allattrs['target']) { + attrsJson['data-sys-asset-isnewtab'] = allattrs['target'] === "_blank" + delete attrsJson['target'] + } + if (!attrsJson['sys-style-type']) { + attrsJson['sys-style-type'] = String(allattrs['asset-type']).indexOf('image') > -1 ? 'display' : 'download' + } + if (attrsJson?.["display-type"] === "display") { + const styleObj = jsonValue?.["attrs"]?.["style"] ?? {} + if (!styleObj["width"]) { + styleObj["width"] = "auto" + } + delete styleObj["float"] + attrsJson["style"] = getStyleStringFromObject(styleObj) + } + delete attrsJson['display-type'] + } + } + if (jsonValue['type'] === "style") { + delete attrsJson['style-text'] + } + if (jsonValue['type'] === 'img') { + attrsJson['src'] = allattrs['url'] + if (allattrs['caption']) figureStyles.caption = allattrs['caption'] + if (allattrs['position']) figureStyles.position = allattrs['position'] + if (allattrs['anchorLink']) figureStyles.anchorLink = `href="${allattrs['anchorLink']}"` + if (allattrs['target']) { + figureStyles.anchorLink += ` target="${allattrs['target']}"` + } + figureStyles.fieldsEdited.push(figureStyles.caption) + } + + if (jsonValue['type'] === 'social-embeds' || jsonValue['type'] === 'embed') { + attrsJson['src'] = encodeURI(allattrs['src']) + } + + if (!(options?.customElementTypes && !isEmpty(options.customElementTypes) && options.customElementTypes[jsonValue['type']])) { + delete attrsJson['url'] + } + delete attrsJson['redactor-attributes'] + + Object.entries(attrsJson).forEach((item) => { + if (forbiddenAttrChars.some(char => item[0].includes(char))) { + return + } + if (localAllowedEmptyAttributes.hasOwnProperty(jsonValue['type']) && localAllowedEmptyAttributes[jsonValue['type']].includes(item[0])) { + attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" ` + return + } + return item[1] ? (item[1] !== '' ? (attrs += `${item[0]}="${replaceHtmlEntities(item[1])}" `) : '') : '' + }) + + attrs = (attrs.trim() ? ' ' : '') + attrs.trim() + } + + // Table colgroup + if (jsonValue['type'] === 'table') { + let colWidths = jsonValue.attrs.colWidths + let totalWidth = colWidths.reduce((a: any, b: any) => a + b, 0) + var setCol = new Set(colWidths) + if (!(setCol.size === 1 && jsonValue.attrs.cols * setCol.values().next().value === totalWidth)) { + let col = '' + Array.from(colWidths).forEach((colWidth, index) => { + const width = (colWidth as number / totalWidth) * 100 + col += `` + }) + let colgroup = `${col}` + children = colgroup + children + } + } + if (jsonValue['type'] === 'check-list') { + attrs = `data-checked='${jsonValue.checked}' data-type='checked'` + } + if (jsonValue['type'] === 'row') { + attrs = `data-type='row' style="max-width:100%;display:flex;"` + } + if (jsonValue['type'] === 'column') { + const { width } = jsonValue?.meta || {} + attrs = `data-type='column' width="${width}" style="flex-grow: 0;flex-shrink: 0;position: relative;width:${width * 100}%; margin: 0 0.25rem;"` + } + if (jsonValue['type'] === 'grid-container') { + const { gutter } = jsonValue.attrs + attrs = `data-type='grid-container' gutter="${gutter}" style="display: flex; width: 100%; gap: ${gutter}px"` + } + if (jsonValue['type'] === 'grid-child') { + const { gridRatio } = jsonValue.attrs + attrs = `data-type='grid-child' grid-ratio="${gridRatio}" style="flex: ${gridRatio}"` + } + if (jsonValue['type'] === 'reference') { + figureStyles.displayType = jsonValue?.attrs?.["display-type"] + } + if (jsonValue['type'] === 'span' && jsonValue.children.length === 1 && jsonValue.children[0].type === 'span') { + if (Object.keys(jsonValue.attrs).length === 0) { + return { earlyReturn: children } + } + } + if (['td', 'th'].includes(jsonValue['type'])) { + if (jsonValue?.['attrs']?.['void']) return { earlyReturn: '' } + } + + attrs = (attrs.trim() ? ' ' : '') + attrs.trim() + + return { attrs, orgType, figureStyles, children } +} + +function getStyleStringFromObject(styleObj: { [key: string]: string }) { + return Object.keys(styleObj) + .map((key) => `${key}: ${styleObj[key]}`) + .join("; ") +} diff --git a/src/types.ts b/src/types.ts index 11404b4..53eb504 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,7 @@ export interface IHtmlToJsonElementTags { [key: string]: (el:HTMLElement) => IHt export interface IJsonToHtmlTextTags { [key: string]: (child:any, value:any) => string } export interface IJsonToHtmlElementTags { [key: string]: (attrs:string,child:string,jsonBlock:IAnyObject,extraProps?:object) => string } +export interface IJsonToHtmlAsyncElementTags { [key: string]: (attrs:string,child:string,jsonBlock:IAnyObject,extraProps?:object) => string | Promise } export interface IJsonToHtmlAllowedEmptyAttributes { [key: string]: string[]; } export interface IJsonToMarkdownElementTags{[key: string]: (attrsJson:IAnyObject,child:string) => string} export interface IJsonToMarkdownTextTags{ [key: string]: (child:any, value:any) => string } @@ -25,3 +26,10 @@ export interface IJsonToHtmlOptions { allowedEmptyAttributes?: IJsonToHtmlAllowedEmptyAttributes, addNbspForEmptyBlocks?: boolean } +export interface IJsonToHtmlAsyncOptions { + customElementTypes?: IJsonToHtmlAsyncElementTags, + customTextWrapper?: IJsonToHtmlTextTags, + allowNonStandardTypes?: boolean, + allowedEmptyAttributes?: IJsonToHtmlAllowedEmptyAttributes, + addNbspForEmptyBlocks?: boolean +} diff --git a/test/toRedactorAsync.test.ts b/test/toRedactorAsync.test.ts new file mode 100644 index 0000000..c8d3c3a --- /dev/null +++ b/test/toRedactorAsync.test.ts @@ -0,0 +1,257 @@ +import { toRedactorAsync } from "../src/toRedactorAsync" +import { toRedactor } from "../src/toRedactor" +import expectedValue from "./expectedJson" + +describe("toRedactorAsync", () => { + describe("parity with sync toRedactor", () => { + it("heading conversion", async () => { + let jsonValue = expectedValue["2"].json + let htmlValue = await toRedactorAsync({ type: "doc", attrs: {}, children: jsonValue }) + expect(htmlValue).toBe(expectedValue['2'].html) + }) + + it("table conversion", async () => { + let jsonValue = expectedValue["3"].json + let htmlValue = await toRedactorAsync({ type: "doc", attrs: {}, children: jsonValue }) + expect(htmlValue).toBe(expectedValue['3'].html) + }) + + it("basic formatting, block and code conversion", async () => { + let jsonValue = expectedValue["4"].json + let htmlValue = await toRedactorAsync({ type: "doc", attrs: {}, children: jsonValue }) + expect(htmlValue).toBe(expectedValue['4'].html) + }) + + it("list and alignment conversion", async () => { + let jsonValue = expectedValue["5"].json + let htmlValue = await toRedactorAsync({ type: "doc", attrs: {}, children: jsonValue }) + expect(htmlValue).toBe(expectedValue['5'].html) + }) + + it("link, divider and property conversion", async () => { + let jsonValue = expectedValue["7"].json + let htmlValue = await toRedactorAsync({ type: "doc", attrs: {}, children: jsonValue }) + expect(htmlValue).toBe(expectedValue['7'].html) + }) + + it("custom ELEMENT_TYPES (sync handlers)", async () => { + let cases = ["15", "16", "18"] + for (const index of cases) { + let json = expectedValue[index]?.json + let htmlValue = await toRedactorAsync( + { type: "doc", attrs: {}, children: json }, + { customElementTypes: expectedValue[index].customElementTypes }, + ) + expect(htmlValue).toBe(expectedValue[index].html) + } + }) + + it("custom TEXT_WRAPPER", async () => { + let cases = ["17"] + for (const index of cases) { + let json = expectedValue[index]?.json + let htmlValue = await toRedactorAsync( + { type: "doc", attrs: {}, children: json }, + { customTextWrapper: expectedValue[index].customTextWrapper }, + ) + expect(htmlValue).toBe(expectedValue[index].html) + } + }) + + it("produces identical output to sync version for all standard test cases", async () => { + const testCases = ["2", "3", "4", "5", "7"] + for (const index of testCases) { + const json = { type: "doc", attrs: {}, children: expectedValue[index].json } + const syncHtml = toRedactor(json) + const asyncHtml = await toRedactorAsync(json) + expect(asyncHtml).toBe(syncHtml) + } + }) + }) + + describe("async customElementTypes", () => { + it("supports async element type handlers", async () => { + const json = { + type: "doc", + attrs: {}, + children: [ + { + type: "p", + attrs: {}, + children: [{ text: "before" }], + }, + { + type: "custom-widget", + attrs: { id: "widget-1" }, + children: [{ text: "" }], + }, + { + type: "p", + attrs: {}, + children: [{ text: "after" }], + }, + ], + } + + const htmlValue = await toRedactorAsync(json, { + allowNonStandardTypes: true, + customElementTypes: { + "custom-widget": async (attrs, child, jsonBlock) => { + // Simulate async operation (e.g. dynamic import, API call) + await new Promise((resolve) => setTimeout(resolve, 10)) + return `
loaded
` + }, + }, + }) + + expect(htmlValue).toBe( + '

before

loaded

after

', + ) + }) + + it("supports mixed sync and async handlers", async () => { + const json = { + type: "doc", + attrs: {}, + children: [ + { + type: "sync-type", + attrs: {}, + children: [{ text: "sync content" }], + }, + { + type: "async-type", + attrs: {}, + children: [{ text: "async content" }], + }, + ], + } + + const htmlValue = await toRedactorAsync(json, { + allowNonStandardTypes: true, + customElementTypes: { + "sync-type": (attrs, child) => `
${child}
`, + "async-type": async (attrs, child) => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return `
${child}
` + }, + }, + }) + + expect(htmlValue).toBe( + '
sync content
async content
', + ) + }) + + it("resolves children before passing to async handler", async () => { + const json = { + type: "doc", + attrs: {}, + children: [ + { + type: "async-wrapper", + attrs: {}, + children: [ + { + type: "p", + attrs: {}, + children: [{ text: "nested content" }], + }, + ], + }, + ], + } + + const htmlValue = await toRedactorAsync(json, { + allowNonStandardTypes: true, + customElementTypes: { + "async-wrapper": async (attrs, child) => { + // child should already be resolved HTML + expect(child).toBe("

nested content

") + await new Promise((resolve) => setTimeout(resolve, 10)) + return `
${child}
` + }, + }, + }) + + expect(htmlValue).toBe("

nested content

") + }) + + it("handles multiple concurrent async handlers", async () => { + const json = { + type: "doc", + attrs: {}, + children: [ + { + type: "async-a", + attrs: {}, + children: [{ text: "" }], + }, + { + type: "async-b", + attrs: {}, + children: [{ text: "" }], + }, + { + type: "async-c", + attrs: {}, + children: [{ text: "" }], + }, + ], + } + + const order: string[] = [] + + const htmlValue = await toRedactorAsync(json, { + allowNonStandardTypes: true, + customElementTypes: { + "async-a": async () => { + await new Promise((resolve) => setTimeout(resolve, 30)) + order.push("a") + return "
a
" + }, + "async-b": async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + order.push("b") + return "
b
" + }, + "async-c": async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + order.push("c") + return "
c
" + }, + }, + }) + + // Output order should be correct regardless of resolution order + expect(htmlValue).toBe("
a
b
c
") + // Handlers should resolve concurrently (b finishes first) + expect(order).toEqual(["b", "c", "a"]) + }) + + it("propagates errors from async handlers", async () => { + const json = { + type: "doc", + attrs: {}, + children: [ + { + type: "failing-type", + attrs: {}, + children: [{ text: "" }], + }, + ], + } + + await expect( + toRedactorAsync(json, { + allowNonStandardTypes: true, + customElementTypes: { + "failing-type": async () => { + throw new Error("Component failed to load") + }, + }, + }), + ).rejects.toThrow("Component failed to load") + }) + }) +}) From 3997e79b32ce4bb2be49751329504c62b7df8edf Mon Sep 17 00:00:00 2001 From: Jeremy John Date: Fri, 8 May 2026 12:56:09 -0500 Subject: [PATCH 62/66] =?UTF-8?q?feat:=20add=20jsonToReact=20=E2=80=94=20R?= =?UTF-8?q?eact=20element=20tree=20output=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add toReactTree.tsx which walks the JSON RTE document and returns ReactNode instead of HTML strings. Handlers receive (jsonBlock, children) and return ReactNode directly, enabling real React component rendering without renderToStaticMarkup. - New exports: jsonToReact, IJsonToReactOptions, IJsonToReactElementHandler, IJsonToReactTextHandler - React is a peer dependency (optional) and externalized from the bundle - Default handlers map all standard element types to JSX equivalents - Text mark handlers (bold, italic, etc.) wrap children in semantic elements --- package-lock.json | 105 ++++++++++++++++++++++- package.json | 14 +++- src/index.tsx | 3 +- src/toReactTree.tsx | 200 ++++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 17 ++++ 5 files changed, 334 insertions(+), 5 deletions(-) create mode 100644 src/toReactTree.tsx diff --git a/package-lock.json b/package-lock.json index 375799e..d906f13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@contentstack/json-rte-serializer", - "version": "3.0.4", + "version": "3.0.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@contentstack/json-rte-serializer", - "version": "3.0.4", + "version": "3.0.5", "license": "MIT", "dependencies": { "array-flat-polyfill": "^1.0.1", @@ -34,14 +34,24 @@ "@types/lodash.isundefined": "^3.0.9", "@types/lodash.kebabcase": "^4.1.9", "@types/omit-deep-lodash": "^1.1.1", + "@types/react": "^18.0.0", "@types/uuid": "^8.3.0", "esbuild": "0.19.11", "jest": "^27.5.1", "jest-html-reporter": "^3.7.0", "jsdom": "^16.6.0", "omit-deep-lodash": "^1.1.5", + "react": "^18.0.0", "ts-jest": "^27.0.3", "typescript": "^4.4.2" + }, + "peerDependencies": { + "react": ">=16" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } } }, "node_modules/@ampproject/remapping": { @@ -1596,6 +1606,24 @@ "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", "dev": true }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -2142,6 +2170,13 @@ "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", "dev": true }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/data-urls": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", @@ -3887,6 +3922,19 @@ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4311,6 +4359,19 @@ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "dev": true }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -6254,6 +6315,22 @@ "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", "dev": true }, + "@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true + }, + "@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, "@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -6663,6 +6740,12 @@ } } }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, "data-urls": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", @@ -7998,6 +8081,15 @@ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, "lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -8321,6 +8413,15 @@ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "dev": true }, + "react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0" + } + }, "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index 721cc6a..8dd2626 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "scripts": { "test": "jest", "prepare": "npm run build", - "build:cjs": "esbuild src/index.tsx --bundle --outdir=lib --platform=node --minify", - "build:esm": "esbuild src/index.tsx --bundle --outdir=lib --format=esm --out-extension:.js=.mjs --minify", + "build:cjs": "esbuild src/index.tsx --bundle --outdir=lib --platform=node --minify --external:react", + "build:esm": "esbuild src/index.tsx --bundle --outdir=lib --format=esm --out-extension:.js=.mjs --minify --external:react", "build": "npm run build:cjs && npm run build:esm && tsc --emitDeclarationOnly --outDir lib" }, "repository": { @@ -38,12 +38,14 @@ "@types/lodash.isundefined": "^3.0.9", "@types/lodash.kebabcase": "^4.1.9", "@types/omit-deep-lodash": "^1.1.1", + "@types/react": "^18.0.0", "@types/uuid": "^8.3.0", "esbuild": "0.19.11", "jest": "^27.5.1", "jest-html-reporter": "^3.7.0", "jsdom": "^16.6.0", "omit-deep-lodash": "^1.1.5", + "react": "^18.0.0", "ts-jest": "^27.0.3", "typescript": "^4.4.2" }, @@ -61,6 +63,14 @@ "slate": "^0.103.0", "uuid": "^8.3.2" }, + "peerDependencies": { + "react": ">=16" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, "files": [ "lib/**/*" ] diff --git a/src/index.tsx b/src/index.tsx index c90be0c..acecf49 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,6 +2,7 @@ import "array-flat-polyfill" import { fromRedactor } from "./fromRedactor" import { toRedactor } from "./toRedactor" import { toRedactorAsync } from "./toRedactorAsync" +import { toReactTree } from "./toReactTree" import {jsonToMarkdownSerializer} from './jsonToMarkdown' export * from "./types" -export { fromRedactor as htmlToJson, toRedactor as jsonToHtml, toRedactorAsync as jsonToHtmlAsync, jsonToMarkdownSerializer as jsonToMarkdown } \ No newline at end of file +export { fromRedactor as htmlToJson, toRedactor as jsonToHtml, toRedactorAsync as jsonToHtmlAsync, toReactTree as jsonToReact, jsonToMarkdownSerializer as jsonToMarkdown } \ No newline at end of file diff --git a/src/toReactTree.tsx b/src/toReactTree.tsx new file mode 100644 index 0000000..5554482 --- /dev/null +++ b/src/toReactTree.tsx @@ -0,0 +1,200 @@ +import React, { ReactNode } from 'react' +import { IJsonToReactOptions, IJsonToReactElementHandler, IJsonToReactTextHandler } from './types' + +// --------------------------------------------------------------------------- +// Default text mark wrappers (bold → , italic → , etc.) +// --------------------------------------------------------------------------- +const DEFAULT_TEXT_MARKS: Record = { + bold: (children) => {children}, + italic: (children) => {children}, + underline: (children) => {children}, + strikethrough: (children) => {children}, + superscript: (children) => {children}, + subscript: (children) => {children}, + inlineCode: (children) => {children}, +} + +// --------------------------------------------------------------------------- +// Default element handlers — produce standard HTML elements +// --------------------------------------------------------------------------- +const DEFAULT_ELEMENT_TYPES: Record = { + p: (_jsonBlock, children) =>

{children}

, + h1: (_jsonBlock, children) =>

{children}

, + h2: (_jsonBlock, children) =>

{children}

, + h3: (_jsonBlock, children) =>

{children}

, + h4: (_jsonBlock, children) =>

{children}

, + h5: (_jsonBlock, children) =>
{children}
, + h6: (_jsonBlock, children) =>
{children}
, + blockquote: (_jsonBlock, children) =>
{children}
, + code: (_jsonBlock, children) =>
{children}
, + ol: (_jsonBlock, children) =>
    {children}
, + ul: (_jsonBlock, children) =>
    {children}
, + li: (_jsonBlock, children) =>
  • {children}
  • , + a: (jsonBlock, children) => { + const { url, target } = jsonBlock.attrs || {} + return {children} + }, + img: (jsonBlock) => { + const attrs = jsonBlock.attrs || {} + const src = attrs['redactor-attributes']?.['asset-link'] || attrs.url || attrs.src + const alt = attrs['redactor-attributes']?.alt || attrs.alt || '' + return {alt} + }, + embed: (jsonBlock) => { + const src = jsonBlock.attrs?.src + return