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

before

loaded

after

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

nested content

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

nested content

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

{children}

, + h1: (_jsonBlock, children) =>

{children}

, + h2: (_jsonBlock, children) =>

{children}

, + h3: (_jsonBlock, children) =>

{children}

, + h4: (_jsonBlock, children) =>

{children}

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