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 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 }} diff --git a/.github/workflows/policy-scan.yml b/.github/workflows/policy-scan.yml new file mode 100644 index 0000000..ff25923 --- /dev/null +++ b/.github/workflows/policy-scan.yml @@ -0,0 +1,46 @@ +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: | + 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 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 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 diff --git a/.talismanrc b/.talismanrc index 3fd67ab..77ed317 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 @@ -21,3 +24,12 @@ fileignoreconfig: checksum: 3d014702628ad538065c970d988a695af003c61663596a8f6b9267b4e57ef6ea - filename: test/expectedJson.json checksum: 9979f84be3e5aa27f24381a0c49e0e6696388d19615c4f3b09082780968236ee +- filename: README.md + 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/CODEOWNERS b/CODEOWNERS index 1be7e0d..49adcb9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1,11 @@ -* @contentstack/security-admin +* @contentstack/venus-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 diff --git a/LICENSE b/LICENSE index 46147ae..ecd01a2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023-2024 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 diff --git a/README.md b/README.md index 5921e5d..ef93d64 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,28 @@ 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 +___ + + `allowedEmptyAttributes` + +- 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. ```javascript @@ -189,6 +211,16 @@ const jsonValue = { }, ], }, + { + "type": "p", + "uid": "28c837c127504d3c85b9cb6d7099cb0b", + "attrs": {}, + "children": [ + { + "text": "" + } + ] + }, { type: "p", attrs: {}, @@ -215,6 +247,11 @@ const htmlValue = jsonToHtml( return `${child}`; }, }, + allowedEmptyAttributes : { + "p": ["dir"], + "img" : ["width"] + }, + addNbspForEmptyBlocks : true } ); @@ -356,12 +393,43 @@ 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 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. 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. ### 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. @@ -542,6 +610,129 @@ The resulting JSON-formatted data will look as follows: } ``` +## Async JSON to HTML Conversion + +For scenarios where custom element handlers need to perform asynchronous operations (e.g., resolving dynamic imports, fetching data), use `jsonToHtmlAsync`: + +```typescript +import { jsonToHtmlAsync } from "@contentstack/json-rte-serializer"; + +const html = await jsonToHtmlAsync(jsonValue, { + customElementTypes: { + // Sync handlers still work + p: (attrs, child) => `${child}

`, + + // Async handlers are now supported + reference: async (attrs, child, jsonBlock) => { + const mod = await import(`./components/${jsonBlock.attrs.type}`); + return renderToStaticMarkup(); + }, + }, +}); +``` + +`jsonToHtmlAsync` has the same API as `jsonToHtml` — the only difference is that handler return values are `await`ed, so both `string` and `Promise` work. Children are resolved concurrently via `Promise.all`. + +## Generic Tree Walker — `toTree()` + +For consumers who need output other than HTML strings (React elements, Preact vnodes, Vue render functions, etc.), `toTree()` provides a framework-agnostic tree walker. You supply the construction callbacks; the walker handles recursion, text marks, and line breaks. + +```typescript +import { toTree, IJsonToTreeOptions } from "@contentstack/json-rte-serializer"; + +const options: IJsonToTreeOptions = { + // Required: map element types to your output format + elementTypes: { + p: (jsonBlock, children) => myCreateElement("p", children), + h1: (jsonBlock, children) => myCreateElement("h1", children), + a: (jsonBlock, children) => myCreateElement("a", { href: jsonBlock.attrs?.url }, children), + // ...add handlers for each type you need + }, + + // Required: how to create a text node in your output format + createText: (text) => text, + + // Required: how to create a line break + createLineBreak: (key) => myCreateElement("br", { key }), + + // Required: how to combine multiple children into one + combineChildren: (children) => myCreateFragment(children), + + // Optional: text mark wrappers (bold, italic, etc.) + textMarks: { + bold: (children) => myCreateElement("strong", children), + italic: (children) => myCreateElement("em", children), + }, + + // Optional: wrap text nodes that have classname/id attrs + wrapTextAttrs: (node, { classname, id }) => + myCreateElement("span", { className: classname, id }, node), + + // Optional: wrap text nodes with inline styles + wrapTextStyle: (node, style) => + myCreateElement("span", { style }, node), + + // Optional: assign a key to an element (for keyed lists in virtual DOM frameworks) + keyElement: (element, key) => myAssignKey(element, key), +}; + +const result = toTree(jsonRteDocument, options); +``` + +### React Reference Implementation (`/react`) + +This package includes a ready-to-use React entry point at `@contentstack/json-rte-serializer/react` that implements all the callbacks above for React. It exports: + +- **`reactPrimitives`** — the `createText`, `createLineBreak`, `combineChildren`, `wrapTextAttrs`, `wrapTextStyle`, and `keyElement` callbacks for React +- **`defaultElementTypes`** — handlers for all standard JSON RTE element types (`p`, `h1`–`h6`, `a`, `img`, `table`, lists, grid, etc.) +- **`defaultTextMarks`** — handlers for bold, italic, underline, strikethrough, superscript, subscript, inlineCode +- **`jsonToReact()`** — convenience wrapper that combines all of the above + +```tsx +import { jsonToReact } from "@contentstack/json-rte-serializer/react"; + +// Basic usage — renders all standard types out of the box +const content = jsonToReact(jsonRteDocument); + +// With custom overrides +const content = jsonToReact(jsonRteDocument, { + customElementTypes: { + // Override specific handlers — merged on top of defaults + reference: (jsonBlock, children) => { + return ; + }, + a: (jsonBlock, children) => { + return {children}; + }, + }, + customTextMarks: { + // Add custom text marks or override defaults + highlight: (children) => {children}, + }, +}); + +// Render directly — no dangerouslySetInnerHTML needed +return
{content}
; +``` + +Unlike `jsonToHtml` (which produces strings requiring `dangerouslySetInnerHTML`), `jsonToReact` returns real React elements. Components rendered from handlers participate in the normal React lifecycle — hooks, context, Suspense, and lazy loading all work naturally. + +You can also use `toTree` directly with the exported primitives for full control: + +```tsx +import { toTree } from "@contentstack/json-rte-serializer"; +import { reactPrimitives, defaultElementTypes, defaultTextMarks } from "@contentstack/json-rte-serializer/react"; + +const result = toTree(jsonRteDocument, { + ...reactPrimitives, + elementTypes: { + ...defaultElementTypes, + // your overrides here + }, + textMarks: defaultTextMarks, +}); +``` + # Documentation Refer to our [JSON Rich Text Editor](https://www.contentstack.com/docs/developers/json-rich-text-editor/) documentation for more information. diff --git a/package-lock.json b/package-lock.json index f88724b..d906f13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@contentstack/json-rte-serializer", - "version": "2.0.7", + "version": "3.0.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@contentstack/json-rte-serializer", - "version": "2.0.7", + "version": "3.0.5", "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", @@ -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", @@ -3836,9 +3871,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", @@ -3886,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", @@ -4310,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", @@ -6253,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", @@ -6662,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", @@ -7947,9 +8031,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", @@ -7997,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", @@ -8320,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 48f0f16..ce76ff0 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,27 @@ { "name": "@contentstack/json-rte-serializer", - "version": "2.0.12", + "version": "3.0.5", "description": "This Package converts Html Document to Json and vice-versa.", "main": "lib/index.js", "module": "lib/index.mjs", "types": "lib/index.d.ts", + "exports": { + ".": { + "require": "./lib/index.js", + "import": "./lib/index.mjs", + "types": "./lib/index.d.ts" + }, + "./react": { + "require": "./lib/react.js", + "import": "./lib/react.mjs", + "types": "./lib/react.d.ts" + } + }, "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 src/react.tsx --bundle --outdir=lib --platform=node --minify --external:react", + "build:esm": "esbuild src/index.tsx src/react.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,18 +50,20 @@ "@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" }, "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", @@ -61,6 +75,14 @@ "slate": "^0.103.0", "uuid": "^8.3.2" }, + "peerDependencies": { + "react": ">=16" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, "files": [ "lib/**/*" ] 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 da4ef56..0439144 100644 --- a/src/fromRedactor.tsx +++ b/src/fromRedactor.tsx @@ -7,125 +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'); - - attrs.url = href ? href : '#'; - - if(target && target !== '') { - attrs.target = target; - } - - 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() !== '' @@ -197,8 +84,17 @@ 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 (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 @@ -206,6 +102,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 = {} @@ -286,17 +185,12 @@ 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) - } - if(options?.customTextTags && !isEmpty(options.customTextTags)) { - Object.assign(TEXT_TAGS, 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) + if (children.length === 0) { children = [{ text: '' }] } @@ -309,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(ELEMENT_TAGS).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) => { @@ -527,7 +421,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') { @@ -545,7 +439,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) @@ -558,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 = { @@ -582,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 } } } @@ -864,7 +773,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 } }) @@ -894,8 +803,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) => { @@ -1006,16 +915,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/index.tsx b/src/index.tsx index d1e48ff..f52722b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,9 @@ import "array-flat-polyfill" import { fromRedactor } from "./fromRedactor" import { toRedactor } from "./toRedactor" +import { toRedactorAsync } from "./toRedactorAsync" +import { toTree } from "./toTree" import {jsonToMarkdownSerializer} from './jsonToMarkdown' export * from "./types" -export { fromRedactor as htmlToJson, toRedactor as jsonToHtml, jsonToMarkdownSerializer as jsonToMarkdown } \ No newline at end of file +export { toTree, IJsonToTreeOptions } from "./toTree" +export { fromRedactor as htmlToJson, toRedactor as jsonToHtml, toRedactorAsync as jsonToHtmlAsync, jsonToMarkdownSerializer as jsonToMarkdown } diff --git a/src/jsonToMarkdown.tsx b/src/jsonToMarkdown.tsx index 1a7b84b..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/react.tsx b/src/react.tsx new file mode 100644 index 0000000..9f5770d --- /dev/null +++ b/src/react.tsx @@ -0,0 +1,182 @@ +/** + * React primitives for toTree. + * + * Import from '@contentstack/json-rte-serializer/react' to get: + * - jsonToReact() — convenience wrapper around toTree + * - reactPrimitives — createText, createLineBreak, combineChildren, etc. + * - defaultElementTypes — p→

, h1→

, a→, etc. + * - defaultTextMarks — bold→, italic→, etc. + * + * These are the building blocks. Use them directly, spread/override them, + * or ignore them and build your own IJsonToTreeOptions from scratch. + */ + +import React, { ReactNode } from 'react' +import { toTree, IJsonToTreeOptions } from './toTree' + +// --------------------------------------------------------------------------- +// Primitives — the low-level callbacks for toTree +// --------------------------------------------------------------------------- + +export const reactPrimitives = { + createText: (text: string): ReactNode => text, + createLineBreak: (key: string): ReactNode =>
, + combineChildren: (children: ReactNode[]): ReactNode => <>{children}, + wrapTextAttrs: (node: ReactNode, attrs: { classname?: string; id?: string }): ReactNode => ( + {node} + ), + wrapTextStyle: (node: ReactNode, style: { color?: string; fontFamily?: string; fontSize?: string }): ReactNode => { + const cssStyle: React.CSSProperties = {} + if (style.color) cssStyle.color = style.color + if (style.fontFamily) cssStyle.fontFamily = style.fontFamily + if (style.fontSize) cssStyle.fontSize = style.fontSize + return {node} + }, + keyElement: (element: ReactNode, key: string): ReactNode => { + if (React.isValidElement(element)) { + return React.cloneElement(element, { key }) + } + return element + }, +} as const satisfies Omit, 'elementTypes' | 'textMarks'> + +// --------------------------------------------------------------------------- +// Default text mark handlers +// --------------------------------------------------------------------------- + +export const defaultTextMarks: Record ReactNode> = { + bold: (children) => {children}, + italic: (children) => {children}, + underline: (children) => {children}, + strikethrough: (children) => {children}, + superscript: (children) => {children}, + subscript: (children) => {children}, + inlineCode: (children) => {children}, +} + +// --------------------------------------------------------------------------- +// HTML content model helpers +// --------------------------------------------------------------------------- + +/** + * JSON RTE types whose rendered output is phrasing (inline) content. + * Text nodes (which have no `type`) are implicitly phrasing. + * Any child type NOT in this set is assumed to produce block-level output + * and will cause phrasing-only parents (p, h1–h6) to upgrade to
` - }, - '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 = ``; +export const toRedactor = (jsonValue: any, options?: IJsonToHtmlOptions): string => { + const locals = initLocals(options) + return _toRedactor(jsonValue, options, locals) +} - if (anchor) { - const target = jsonBlock?.["attrs"]?.["target"]; - let anchorAttrs = `href="${anchor}"`; - if (target) { - anchorAttrs = `${anchorAttrs} target="${target}"`; - } - img = `${img}`; - } +function _toRedactor(jsonValue: any, options: IJsonToHtmlOptions | undefined, locals: ILocals): string { + const { localTextWrappers, localElementTypes, localAllowedEmptyAttributes, addNbspForEmptyBlocks } = locals - 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}` - }, -} -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 (jsonValue.hasOwnProperty('text')) { - let text = jsonValue['text'].replace(//g, '>') - if (jsonValue['break']) { - text += `
` - } - 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(TEXT_WRAPPERS.hasOwnProperty(key)){ - text = TEXT_WRAPPERS[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)) { - Object.assign(ELEMENT_TYPES, 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(ELEMENT_TYPES).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 (ELEMENT_TYPES[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 (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) => { - style += `${kebbab(key)}: ${allattrs.style[key]};` - }) - delete allattrs['style'] - } - 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'] - } + const nonStandard = processNonStandardType(jsonValue, children, localElementTypes, options?.allowNonStandardTypes) + if (nonStandard !== null) return nonStandard - if (allattrs['link']) { - attrsJson['data-sys-asset-link'] = allattrs['link'] - delete attrsJson['link'] - } + if (localElementTypes[jsonValue['type']]) { + const result = processElementNode(jsonValue, children, options, localElementTypes, localAllowedEmptyAttributes) + if ('earlyReturn' in result) return result.earlyReturn - 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(!(options?.customElementTypes && !isEmpty(options.customElementTypes) && options.customElementTypes[jsonValue['type']])) { - delete attrsJson['url'] - } - delete attrsJson['redactor-attributes'] - Object.entries(attrsJson).forEach((key) => { - return key[1] ? (key[1] !== '' ? (attrs += `${key[0]}="${key[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() - - return ELEMENT_TYPES[orgType || jsonValue['type']](attrs, 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..018c517 --- /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["font-family"]}";` + } + if (style["font-size"]) { + attrsStyle += `font-size: ${style["font-size"]};` + } + 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['asset-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) + const 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/toTree.ts b/src/toTree.ts new file mode 100644 index 0000000..df2f8b6 --- /dev/null +++ b/src/toTree.ts @@ -0,0 +1,143 @@ +/** + * Generic JSON RTE tree walker. + * + * Converts a Contentstack JSON RTE document into an arbitrary output type `T` + * by delegating all construction to caller-provided callbacks. The walker owns + * the recursion logic; the caller owns the output format (React elements, + * HTML strings, Preact vnodes, etc.). + */ + +export interface IJsonToTreeOptions { + /** Map element type → handler. Receives (jsonBlock, children) and returns T. */ + elementTypes: Record T> + /** Map text mark name → handler. Wraps already-built children in a mark. */ + textMarks?: Record T> + /** Convert a raw text string to T. */ + createText: (text: string) => T + /** Create a line break. */ + createLineBreak: (key: string) => T + /** Combine an array of children into a single T. */ + combineChildren: (children: T[]) => T + /** Wrap a text node with classname/id metadata. */ + wrapTextAttrs?: (node: T, attrs: { classname?: string; id?: string }) => T + /** Wrap a text node with inline styles. */ + wrapTextStyle?: (node: T, style: { color?: string; fontFamily?: string; fontSize?: string }) => T + /** Assign a stable key/identifier to an element (React: cloneElement with key; strings: no-op). */ + keyElement?: (element: T, key: string) => T +} + +// --------------------------------------------------------------------------- +// Text node processing +// --------------------------------------------------------------------------- +function processTextNode( + jsonValue: any, + opts: IJsonToTreeOptions, + parentKey: string, +): T { + const textMarks = opts.textMarks ?? {} + + // Start with raw text + let node: T + + // Handle line breaks + if (jsonValue.break || (jsonValue.text && jsonValue.text.includes('\n'))) { + const parts = String(jsonValue.text).split('\n') + const pieces: T[] = [] + for (let i = 0; i < parts.length; i++) { + if (i > 0) pieces.push(opts.createLineBreak(`${parentKey}-br-${i}`)) + if (parts[i]) pieces.push(opts.createText(parts[i])) + } + node = pieces.length === 1 ? pieces[0] : opts.combineChildren(pieces) + } else { + node = opts.createText(jsonValue.text) + } + + // Apply text marks (bold, italic, etc.) + for (const [mark, handler] of Object.entries(textMarks)) { + if (jsonValue[mark]) { + node = handler(node, jsonValue[mark]) + } + } + + // Handle classname/id wrapping + if ((jsonValue.classname || jsonValue.id) && opts.wrapTextAttrs) { + node = opts.wrapTextAttrs(node, { + classname: jsonValue.classname || undefined, + id: jsonValue.id || undefined, + }) + } + + // Handle inline styles on text + if (jsonValue.attrs?.style && opts.wrapTextStyle) { + const { color, 'font-family': fontFamily, 'font-size': fontSize } = jsonValue.attrs.style + if (color || fontFamily || fontSize) { + node = opts.wrapTextStyle(node, { color, fontFamily, fontSize }) + } + } + + return node +} + +// --------------------------------------------------------------------------- +// Recursive tree walker +// --------------------------------------------------------------------------- +function walkNode( + jsonValue: any, + opts: IJsonToTreeOptions, + key: string, +): T | null { + // Text leaf node + if (jsonValue.hasOwnProperty('text')) { + if (!jsonValue.text && jsonValue.text !== '') return null + return processTextNode(jsonValue, opts, key) + } + + // Recursively render children + let children: T | null = null + if (jsonValue.children && Array.isArray(jsonValue.children)) { + const childNodes: T[] = [] + for (let i = 0; i < jsonValue.children.length; i++) { + const child = walkNode(jsonValue.children[i], opts, `${key}-${i}`) + if (child != null) childNodes.push(child) + } + if (childNodes.length === 1) { + children = childNodes[0] + } else if (childNodes.length > 1) { + children = opts.combineChildren(childNodes) + } + } + + // Doc root — just return children + if (jsonValue.type === 'doc' || !jsonValue.type) { + return children + } + + // Look up handler + const handler = opts.elementTypes[jsonValue.type] + if (handler) { + const element = handler(jsonValue, children) + // Assign key if the caller provided a keyElement callback + if (opts.keyElement) { + return opts.keyElement(element, key) + } + return element + } + + // Fallback: unknown type — pass children through + return children +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Convert a Contentstack JSON RTE document into an arbitrary output type `T`. + * + * The walker handles recursion; the caller provides all construction callbacks + * via `options`. This makes the function agnostic to the output format — + * React elements, Preact vnodes, HTML strings, or anything else. + */ +export function toTree(jsonValue: any, options: IJsonToTreeOptions): T | null { + return walkNode(jsonValue, options, 'rte') +} diff --git a/src/types.ts b/src/types.ts index adb3785..53eb504 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, @@ -14,10 +15,21 @@ 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 } export interface IJsonToHtmlOptions { customElementTypes?: IJsonToHtmlElementTags, customTextWrapper?: IJsonToHtmlTextTags, allowNonStandardTypes?: boolean, + allowedEmptyAttributes?: IJsonToHtmlAllowedEmptyAttributes, + addNbspForEmptyBlocks?: boolean +} +export interface IJsonToHtmlAsyncOptions { + customElementTypes?: IJsonToHtmlAsyncElementTags, + customTextWrapper?: IJsonToHtmlTextTags, + allowNonStandardTypes?: boolean, + allowedEmptyAttributes?: IJsonToHtmlAllowedEmptyAttributes, + addNbspForEmptyBlocks?: boolean } diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..df48a1c --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,9 @@ +export function replaceHtmlEntities(str: string): string { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +export const forbiddenAttrChars = ['"', "'", '>','<', '/', '=']; \ No newline at end of file diff --git a/test/expectedJson.ts b/test/expectedJson.ts index 777a02d..d578492 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": [ { @@ -1869,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", @@ -1999,6 +1999,494 @@ export default { } ], "htmlUpdated": "

" + }, + "RT-360":{ + "html": [ + ``, + ``, + '', + ``, + ``, + `` + ], + "json": + [ + { + "type": "doc", + "attrs": {}, + "uid": "18396bf67f1f4b0a9da57643ac0542ca", + "children": [ + { + "uid": "45a850acbeb949db86afe415625ad1ce", + "type": "social-embeds", + "attrs": { + "src": "https://www.youtube.com/watch?v=Gw7EqoOYC9A\">`); }) + + describe("RT-360", () =>{ + 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]); + }) + + it("should handle undefined or null cases",()=>{ + const json = expectedValue["RT-360"].json[1] + const html = toRedactor(json); + expect(html).toBe(expectedValue["RT-360"].html[1]); + }) + + 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]); + }) + + 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]); + }) + + 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', () => { + 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.`) + }) + + 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]); + }) + + }) + + 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]); + }) + }) + + 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); + }) + + }) + +}) + +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

`); +}); + +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
`); +}) + +describe("inline text styles", () => { + test("should render font-family from text attrs", () => { + const json = { + type: "doc", attrs: {}, children: [{ + type: "p", attrs: {}, children: [{ + text: "styled", + attrs: { style: { "font-family": "Arial" } } + }] + }] + } + const html = toRedactor(json) + expect(html).toBe('

styled

') + }) + + test("should render font-size from text attrs", () => { + const json = { + type: "doc", attrs: {}, children: [{ + type: "p", attrs: {}, children: [{ + text: "sized", + attrs: { style: { "font-size": "20px" } } + }] + }] + } + const html = toRedactor(json) + expect(html).toBe('

sized

') + }) + + test("should render color from text attrs", () => { + const json = { + type: "doc", attrs: {}, children: [{ + type: "p", attrs: {}, children: [{ + text: "colored", + attrs: { style: { color: "red" } } + }] + }] + } + const html = toRedactor(json) + expect(html).toBe("

colored

") + }) + + test("should render all text style properties together", () => { + const json = { + type: "doc", attrs: {}, children: [{ + type: "p", attrs: {}, children: [{ + text: "all", + attrs: { style: { color: "blue", "font-family": "Helvetica", "font-size": "14px" } } + }] + }] + } + const html = toRedactor(json) + expect(html).toBe(`

all

`) + }) }) +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

') + }) +}) 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") + }) + }) +}) diff --git a/test/toTree.test.ts b/test/toTree.test.ts new file mode 100644 index 0000000..3599b7a --- /dev/null +++ b/test/toTree.test.ts @@ -0,0 +1,338 @@ +import { toTree, IJsonToTreeOptions } from '../src/toTree' + +/** + * String-based primitives for testing the generic walker + * without any framework dependency. + */ +const stringOpts: IJsonToTreeOptions = { + elementTypes: { + p: (_, ch) => `

${ch ?? ''}

`, + h1: (_, ch) => `

${ch ?? ''}

`, + h2: (_, ch) => `

${ch ?? ''}

`, + blockquote: (_, ch) => `
${ch ?? ''}
`, + ol: (_, ch) => `
    ${ch ?? ''}
`, + ul: (_, ch) => `
    ${ch ?? ''}
`, + li: (_, ch) => `
  • ${ch ?? ''}
  • `, + a: (jb, ch) => `${ch ?? ''}`, + img: (jb) => ``, + hr: () => '
    ', + code: (_, ch) => `
    ${ch ?? ''}
    `, + table: (_, ch) => `${ch ?? ''}
    `, + tr: (_, ch) => `${ch ?? ''}`, + td: (_, ch) => `${ch ?? ''}`, + span: (_, ch) => `${ch ?? ''}`, + div: (_, ch) => `
    ${ch ?? ''}
    `, + reference: (_, ch) => `
    ${ch ?? ''}
    `, + }, + textMarks: { + bold: (ch) => `${ch}`, + italic: (ch) => `${ch}`, + underline: (ch) => `${ch}`, + strikethrough: (ch) => `${ch}`, + superscript: (ch) => `${ch}`, + subscript: (ch) => `${ch}`, + inlineCode: (ch) => `${ch}`, + }, + createText: (text) => text, + createLineBreak: () => '
    ', + combineChildren: (children) => children.join(''), + wrapTextAttrs: (node, attrs) => { + const parts: string[] = [] + if (attrs.classname) parts.push(`class="${attrs.classname}"`) + if (attrs.id) parts.push(`id="${attrs.id}"`) + return `${node}` + }, + wrapTextStyle: (node, style) => { + const parts: string[] = [] + if (style.color) parts.push(`color:${style.color}`) + if (style.fontFamily) parts.push(`font-family:${style.fontFamily}`) + if (style.fontSize) parts.push(`font-size:${style.fontSize}`) + return `${node}` + }, +} + +describe('toTree (generic walker)', () => { + describe('text nodes', () => { + it('renders plain text', () => { + const json = { type: 'doc', children: [{ type: 'p', children: [{ text: 'hello' }] }] } + expect(toTree(json, stringOpts)).toBe('

    hello

    ') + }) + + it('preserves empty string text', () => { + const json = { type: 'doc', children: [{ type: 'p', children: [{ text: '' }] }] } + expect(toTree(json, stringOpts)).toBe('

    ') + }) + + it('returns null for undefined/null text', () => { + expect(toTree({ text: undefined }, stringOpts)).toBeNull() + expect(toTree({ text: null }, stringOpts)).toBeNull() + }) + + it('converts \\n to line breaks', () => { + const json = { type: 'doc', children: [{ type: 'p', children: [{ text: 'line1\nline2' }] }] } + expect(toTree(json, stringOpts)).toBe('

    line1
    line2

    ') + }) + + it('handles multiple consecutive \\n', () => { + const json = { type: 'doc', children: [{ type: 'p', children: [{ text: 'a\n\nb' }] }] } + expect(toTree(json, stringOpts)).toBe('

    a

    b

    ') + }) + + it('handles break flag with \\n', () => { + const json = { type: 'doc', children: [{ type: 'p', children: [{ text: '\n', break: true }] }] } + expect(toTree(json, stringOpts)).toBe('


    ') + }) + }) + + describe('text marks', () => { + it('applies bold', () => { + const json = { type: 'doc', children: [{ type: 'p', children: [{ text: 'bold', bold: true }] }] } + expect(toTree(json, stringOpts)).toBe('

    bold

    ') + }) + + it('applies italic', () => { + const json = { type: 'doc', children: [{ type: 'p', children: [{ text: 'em', italic: true }] }] } + expect(toTree(json, stringOpts)).toBe('

    em

    ') + }) + + it('stacks multiple marks', () => { + const json = { type: 'doc', children: [{ type: 'p', children: [{ text: 'x', bold: true, italic: true }] }] } + const result = toTree(json, stringOpts)! + expect(result).toContain('') + expect(result).toContain('') + expect(result).toContain('x') + }) + + it('applies underline, strikethrough, super, sub, inlineCode', () => { + const marks = [ + { mark: 'underline', tag: 'u' }, + { mark: 'strikethrough', tag: 'del' }, + { mark: 'superscript', tag: 'sup' }, + { mark: 'subscript', tag: 'sub' }, + { mark: 'inlineCode', tag: 'code' }, + ] + for (const { mark, tag } of marks) { + const json = { type: 'doc', children: [{ type: 'p', children: [{ text: 'txt', [mark]: true }] }] } + expect(toTree(json, stringOpts)).toBe(`

    <${tag}>txt

    `) + } + }) + }) + + describe('text attributes', () => { + it('wraps text with classname', () => { + const json = { type: 'doc', children: [{ type: 'p', children: [{ text: 'classed', classname: 'foo' }] }] } + expect(toTree(json, stringOpts)).toBe('

    classed

    ') + }) + + it('wraps text with id', () => { + const json = { type: 'doc', children: [{ type: 'p', children: [{ text: 'ided', id: 'bar' }] }] } + expect(toTree(json, stringOpts)).toBe('

    ided

    ') + }) + + it('wraps text with inline styles', () => { + const json = { + type: 'doc', + children: [{ type: 'p', children: [{ text: 'styled', attrs: { style: { color: 'red' } } }] }], + } + expect(toTree(json, stringOpts)).toBe('

    styled

    ') + }) + + it('skips style wrap when no relevant properties', () => { + const json = { + type: 'doc', + children: [{ type: 'p', children: [{ text: 'plain', attrs: { style: { 'text-align': 'center' } } }] }], + } + expect(toTree(json, stringOpts)).toBe('

    plain

    ') + }) + }) + + describe('element types', () => { + it('renders headings', () => { + const json = { type: 'doc', children: [{ type: 'h1', children: [{ text: 'Title' }] }] } + expect(toTree(json, stringOpts)).toBe('

    Title

    ') + }) + + it('renders links with attrs', () => { + const json = { + type: 'doc', + children: [{ type: 'a', attrs: { url: 'https://x.com' }, children: [{ text: 'click' }] }], + } + expect(toTree(json, stringOpts)).toBe('click') + }) + + it('renders void elements (hr)', () => { + const json = { type: 'doc', children: [{ type: 'hr', children: [{ text: '' }] }] } + expect(toTree(json, stringOpts)).toBe('
    ') + }) + + it('renders nested lists', () => { + const json = { + type: 'doc', + children: [ + { + type: 'ul', + children: [ + { type: 'li', children: [{ text: 'a' }] }, + { type: 'li', children: [{ text: 'b' }] }, + ], + }, + ], + } + expect(toTree(json, stringOpts)).toBe('
    • a
    • b
    ') + }) + + it('renders tables', () => { + const json = { + type: 'doc', + children: [ + { + type: 'table', + children: [ + { + type: 'tr', + children: [ + { type: 'td', children: [{ text: '1' }] }, + { type: 'td', children: [{ text: '2' }] }, + ], + }, + ], + }, + ], + } + expect(toTree(json, stringOpts)).toBe('
    12
    ') + }) + }) + + describe('doc root handling', () => { + it('unwraps doc type', () => { + const json = { type: 'doc', children: [{ type: 'p', children: [{ text: 'hi' }] }] } + expect(toTree(json, stringOpts)).toBe('

    hi

    ') + }) + + it('unwraps missing type', () => { + const json = { children: [{ type: 'p', children: [{ text: 'hi' }] }] } + expect(toTree(json, stringOpts)).toBe('

    hi

    ') + }) + + it('returns null for empty doc', () => { + expect(toTree({ type: 'doc', children: [] }, stringOpts)).toBeNull() + }) + }) + + describe('unknown types', () => { + it('passes children through for unknown element types', () => { + const json = { type: 'doc', children: [{ type: 'unknown-thing', children: [{ text: 'content' }] }] } + expect(toTree(json, stringOpts)).toBe('content') + }) + }) + + describe('keyElement callback', () => { + it('calls keyElement on each element', () => { + const keys: string[] = [] + const opts: IJsonToTreeOptions = { + ...stringOpts, + keyElement: (el, key) => { + keys.push(key) + return el + }, + } + const json = { + type: 'doc', + children: [ + { type: 'p', children: [{ text: 'a' }] }, + { type: 'p', children: [{ text: 'b' }] }, + ], + } + toTree(json, opts) + expect(keys).toEqual(['rte-0', 'rte-1']) + }) + }) + + describe('complex documents', () => { + it('renders mixed inline content', () => { + const json = { + type: 'doc', + children: [ + { + type: 'p', + children: [ + { text: 'normal ' }, + { text: 'bold', bold: true }, + { text: ' and ' }, + { text: 'italic', italic: true }, + ], + }, + ], + } + expect(toTree(json, stringOpts)).toBe('

    normal bold and italic

    ') + }) + + it('renders multiple sibling paragraphs', () => { + const json = { + type: 'doc', + children: [ + { type: 'p', children: [{ text: 'first' }] }, + { type: 'p', children: [{ text: 'second' }] }, + ], + } + expect(toTree(json, stringOpts)).toBe('

    first

    second

    ') + }) + + it('handles deeply nested structure', () => { + const json = { + type: 'doc', + children: [ + { + type: 'blockquote', + children: [ + { + type: 'p', + children: [{ text: 'quoted ', bold: true }, { text: 'text' }], + }, + ], + }, + ], + } + expect(toTree(json, stringOpts)).toBe('

    quoted text

    ') + }) + }) + + describe('edge cases', () => { + it('handles node with no children array', () => { + const json = { type: 'p' } + expect(toTree(json, stringOpts)).toBe('

    ') + }) + + it('handles children with all-null results', () => { + const json = { + type: 'doc', + children: [{ type: 'p', children: [{ text: undefined }] }], + } + expect(toTree(json, stringOpts)).toBe('

    ') + }) + + it('works with no textMarks provided', () => { + const opts: IJsonToTreeOptions = { + elementTypes: { p: (_, ch) => `

    ${ch ?? ''}

    ` }, + createText: (t) => t, + createLineBreak: () => '
    ', + combineChildren: (ch) => ch.join(''), + } + const json = { type: 'doc', children: [{ type: 'p', children: [{ text: 'hi', bold: true }] }] } + // bold mark is ignored since no textMarks provided + expect(toTree(json, opts)).toBe('

    hi

    ') + }) + + it('works without optional callbacks', () => { + const opts: IJsonToTreeOptions = { + elementTypes: { p: (_, ch) => `

    ${ch ?? ''}

    ` }, + createText: (t) => t, + createLineBreak: () => '
    ', + combineChildren: (ch) => ch.join(''), + } + // classname/id should be ignored when wrapTextAttrs is not provided + const json = { type: 'doc', children: [{ type: 'p', children: [{ text: 'hi', classname: 'x' }] }] } + expect(toTree(json, opts)).toBe('

    hi

    ') + }) + }) +})
    . + * + * This is a whitelist — unknown/custom types default to block-level, + * which is the safe choice for valid HTML nesting. + */ +const PHRASING_TYPES: ReadonlySet = new Set([ + 'a', 'span', 'img', 'fragment', + 'embed', 'social-embeds', +]) + +/** + * Check whether a JSON RTE node contains any children that would produce + * block-level HTML output. Text nodes (no `type` property) are always safe. + */ +function hasBlockChild(jsonBlock: any): boolean { + return jsonBlock.children?.some( + (child: any) => child.type && !PHRASING_TYPES.has(child.type), + ) ?? false +} + +/** + * Wrap children in a phrasing-only element (p, h1–h6), auto-upgrading to + *
    with a CSS class when block-level children are present. + * This prevents invalid HTML nesting (e.g.
    inside

    ) that causes + * hydration mismatches in React SSR. + */ +function phrasingContainer(tag: string, jsonBlock: any, children: ReactNode | null): ReactNode { + if (hasBlockChild(jsonBlock)) { + return

    {children}
    + } + return React.createElement(tag, null, children) +} + +// --------------------------------------------------------------------------- +// Default element type handlers +// --------------------------------------------------------------------------- + +export const defaultElementTypes: Record ReactNode> = { + p: (jb, ch) => phrasingContainer('p', jb, ch), + h1: (jb, ch) => phrasingContainer('h1', jb, ch), + h2: (jb, ch) => phrasingContainer('h2', jb, ch), + h3: (jb, ch) => phrasingContainer('h3', jb, ch), + h4: (jb, ch) => phrasingContainer('h4', jb, ch), + h5: (jb, ch) => phrasingContainer('h5', jb, ch), + h6: (jb, ch) => phrasingContainer('h6', jb, ch), + blockquote: (_, ch) =>
    {ch}
    , + code: (_, ch) =>
    {ch}
    , + ol: (_, ch) =>
      {ch}
    , + ul: (_, ch) =>
      {ch}
    , + li: (_, ch) =>
  • {ch}
  • , + a: (jb, ch) => { + const { url, target } = jb.attrs || {} + return
    {ch} + }, + img: (jb) => { + const attrs = jb.attrs || {} + const src = attrs['redactor-attributes']?.['asset-link'] || attrs.url || attrs.src + const alt = attrs['redactor-attributes']?.alt || attrs.alt || '' + return {alt} + }, + embed: (jb) => ` - }, - 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}` - }, - 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}