diff --git a/README.md b/README.md index 49bdad2..ef93d64 100644 --- a/README.md +++ b/README.md @@ -610,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 375799e..d906f13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@contentstack/json-rte-serializer", - "version": "3.0.4", + "version": "3.0.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@contentstack/json-rte-serializer", - "version": "3.0.4", + "version": "3.0.5", "license": "MIT", "dependencies": { "array-flat-polyfill": "^1.0.1", @@ -34,14 +34,24 @@ "@types/lodash.isundefined": "^3.0.9", "@types/lodash.kebabcase": "^4.1.9", "@types/omit-deep-lodash": "^1.1.1", + "@types/react": "^18.0.0", "@types/uuid": "^8.3.0", "esbuild": "0.19.11", "jest": "^27.5.1", "jest-html-reporter": "^3.7.0", "jsdom": "^16.6.0", "omit-deep-lodash": "^1.1.5", + "react": "^18.0.0", "ts-jest": "^27.0.3", "typescript": "^4.4.2" + }, + "peerDependencies": { + "react": ">=16" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } } }, "node_modules/@ampproject/remapping": { @@ -1596,6 +1606,24 @@ "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", "dev": true }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -2142,6 +2170,13 @@ "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", "dev": true }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/data-urls": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", @@ -3887,6 +3922,19 @@ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4311,6 +4359,19 @@ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "dev": true }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -6254,6 +6315,22 @@ "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", "dev": true }, + "@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true + }, + "@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, "@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -6663,6 +6740,12 @@ } } }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true + }, "data-urls": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", @@ -7998,6 +8081,15 @@ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, "lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -8321,6 +8413,15 @@ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "dev": true }, + "react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0" + } + }, "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index 721cc6a..ce76ff0 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,23 @@ "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,12 +50,14 @@ "@types/lodash.isundefined": "^3.0.9", "@types/lodash.kebabcase": "^4.1.9", "@types/omit-deep-lodash": "^1.1.1", + "@types/react": "^18.0.0", "@types/uuid": "^8.3.0", "esbuild": "0.19.11", "jest": "^27.5.1", "jest-html-reporter": "^3.7.0", "jsdom": "^16.6.0", "omit-deep-lodash": "^1.1.5", + "react": "^18.0.0", "ts-jest": "^27.0.3", "typescript": "^4.4.2" }, @@ -61,6 +75,14 @@ "slate": "^0.103.0", "uuid": "^8.3.2" }, + "peerDependencies": { + "react": ">=16" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, "files": [ "lib/**/*" ] diff --git a/src/index.tsx b/src/index.tsx index 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/react.tsx b/src/react.tsx new file mode 100644 index 0000000..04a855b --- /dev/null +++ b/src/react.tsx @@ -0,0 +1,141 @@ +/** + * 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}, +} + +// --------------------------------------------------------------------------- +// Default element type handlers +// --------------------------------------------------------------------------- + +export const defaultElementTypes: Record ReactNode> = { + p: (_, ch) =>

{ch}

, + h1: (_, ch) =>

{ch}

, + h2: (_, ch) =>

{ch}

, + h3: (_, ch) =>

{ch}

, + h4: (_, ch) =>

{ch}

, + h5: (_, ch) =>
{ch}
, + h6: (_, ch) =>
{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) =>