| null {
+ if (isValidElement(node)) return node.props
+ return null
+}
+
+// ---------------------------------------------------------------------------
+// reactPrimitives
+// ---------------------------------------------------------------------------
+
+describe('reactPrimitives', () => {
+ it('createText returns a string', () => {
+ expect(reactPrimitives.createText('hello')).toBe('hello')
+ })
+
+ it('createLineBreak returns with key', () => {
+ const br = reactPrimitives.createLineBreak('k1')
+ expect(isValidElement(br)).toBe(true)
+ expect(rootType(br)).toBe('br')
+ expect((br as React.ReactElement).key).toBe('k1')
+ })
+
+ it('combineChildren returns a fragment', () => {
+ const result = reactPrimitives.combineChildren(['a', 'b'])
+ expect(isValidElement(result)).toBe(true)
+ expect(rootType(result)).toBe(React.Fragment)
+ })
+
+ it('wrapTextAttrs wraps in span with className and id', () => {
+ const result = reactPrimitives.wrapTextAttrs('text', { classname: 'cls', id: 'myid' })
+ expect(rootType(result)).toBe('span')
+ expect(rootProps(result)?.className).toBe('cls')
+ expect(rootProps(result)?.id).toBe('myid')
+ })
+
+ it('wrapTextStyle wraps in span with inline styles', () => {
+ const result = reactPrimitives.wrapTextStyle('text', { color: 'red', fontFamily: 'Arial', fontSize: '16px' })
+ expect(rootType(result)).toBe('span')
+ expect(rootProps(result)?.style).toEqual({ color: 'red', fontFamily: 'Arial', fontSize: '16px' })
+ })
+
+ it('wrapTextStyle omits undefined style properties', () => {
+ const result = reactPrimitives.wrapTextStyle('text', { color: 'blue' })
+ expect(rootProps(result)?.style).toEqual({ color: 'blue' })
+ })
+
+ it('keyElement assigns key to valid element', () => {
+ const el = React.createElement('p', null, 'hi')
+ const keyed = reactPrimitives.keyElement(el, 'mykey')
+ expect(isValidElement(keyed)).toBe(true)
+ expect((keyed as React.ReactElement).key).toBe('mykey')
+ })
+
+ it('keyElement returns non-elements unchanged', () => {
+ expect(reactPrimitives.keyElement('text', 'k')).toBe('text')
+ })
+})
+
+// ---------------------------------------------------------------------------
+// defaultTextMarks
+// ---------------------------------------------------------------------------
+
+describe('defaultTextMarks', () => {
+ const cases: [string, string][] = [
+ ['bold', 'strong'],
+ ['italic', 'em'],
+ ['underline', 'u'],
+ ['strikethrough', 'del'],
+ ['superscript', 'sup'],
+ ['subscript', 'sub'],
+ ]
+
+ it.each(cases)('%s wraps in <%s>', (mark, tag) => {
+ const result = defaultTextMarks[mark]('text')
+ expect(rootType(result)).toBe(tag)
+ expect(textContent(result)).toBe('text')
+ })
+
+ it('inlineCode wraps in span with data-type', () => {
+ const result = defaultTextMarks.inlineCode('code')
+ expect(rootType(result)).toBe('span')
+ expect(rootProps(result)?.['data-type']).toBe('inlineCode')
+ })
+})
+
+// ---------------------------------------------------------------------------
+// defaultElementTypes
+// ---------------------------------------------------------------------------
+
+describe('defaultElementTypes', () => {
+ const simpleTags: [string, string][] = [
+ ['p', 'p'],
+ ['h1', 'h1'], ['h2', 'h2'], ['h3', 'h3'],
+ ['h4', 'h4'], ['h5', 'h5'], ['h6', 'h6'],
+ ['blockquote', 'blockquote'],
+ ['code', 'pre'],
+ ['ol', 'ol'], ['ul', 'ul'], ['li', 'li'],
+ ['table', 'table'], ['thead', 'thead'], ['tbody', 'tbody'],
+ ['tr', 'tr'], ['td', 'td'], ['th', 'th'],
+ ['span', 'span'], ['div', 'div'],
+ ]
+
+ it.each(simpleTags)('%s renders as <%s>', (type, tag) => {
+ const result = defaultElementTypes[type]({}, 'child')
+ expect(rootType(result)).toBe(tag)
+ })
+
+ it('hr renders self-closing', () => {
+ const result = defaultElementTypes.hr({}, null)
+ expect(rootType(result)).toBe('hr')
+ })
+
+ it('a renders with href and target', () => {
+ const result = defaultElementTypes.a({ attrs: { url: 'https://x.com', target: '_blank' } }, 'link')
+ expect(rootType(result)).toBe('a')
+ expect(rootProps(result)?.href).toBe('https://x.com')
+ expect(rootProps(result)?.target).toBe('_blank')
+ })
+
+ it('a omits target when not set', () => {
+ const result = defaultElementTypes.a({ attrs: { url: 'https://x.com' } }, 'link')
+ expect(rootProps(result)?.target).toBeUndefined()
+ })
+
+ it('img uses redactor-attributes asset-link for src', () => {
+ const result = defaultElementTypes.img({
+ attrs: { 'redactor-attributes': { 'asset-link': 'http://img.jpg' }, alt: 'pic' },
+ }, null)
+ expect(rootType(result)).toBe('img')
+ expect(rootProps(result)?.src).toBe('http://img.jpg')
+ })
+
+ it('img falls back to url then src', () => {
+ const r1 = defaultElementTypes.img({ attrs: { url: 'http://url.jpg' } }, null)
+ expect(rootProps(r1)?.src).toBe('http://url.jpg')
+
+ const r2 = defaultElementTypes.img({ attrs: { src: 'http://src.jpg' } }, null)
+ expect(rootProps(r2)?.src).toBe('http://src.jpg')
+ })
+
+ it('embed renders iframe with src', () => {
+ const result = defaultElementTypes.embed({ attrs: { src: 'http://vid.com' } }, null)
+ expect(rootType(result)).toBe('iframe')
+ expect(rootProps(result)?.src).toBe('http://vid.com')
+ })
+
+ it('social-embeds renders iframe with data-type', () => {
+ const result = defaultElementTypes['social-embeds']({ attrs: { src: 'http://x.com' } }, null)
+ expect(rootType(result)).toBe('iframe')
+ expect(rootProps(result)?.['data-type']).toBe('social-embeds')
+ })
+
+ it('row renders flex div', () => {
+ const result = defaultElementTypes.row({}, 'child')
+ expect(rootProps(result)?.style).toEqual({ maxWidth: '100%', display: 'flex' })
+ })
+
+ it('column renders with width', () => {
+ const result = defaultElementTypes.column({ meta: { width: 0.5 } }, 'child')
+ expect(rootProps(result)?.style?.width).toBe('50%')
+ })
+
+ it('grid-container renders with gap', () => {
+ const result = defaultElementTypes['grid-container']({ attrs: { gutter: 16 } }, 'child')
+ expect(rootProps(result)?.style?.gap).toBe('16px')
+ })
+
+ it('grid-child renders with flex ratio', () => {
+ const result = defaultElementTypes['grid-child']({ attrs: { gridRatio: 2 } }, 'child')
+ expect(rootProps(result)?.style?.flex).toBe(2)
+ })
+
+ it('fragment and trgrp render children directly', () => {
+ for (const type of ['fragment', 'trgrp']) {
+ const result = defaultElementTypes[type]({}, 'child')
+ expect(rootType(result)).toBe(React.Fragment)
+ }
+ })
+
+ it('reference renders div', () => {
+ const result = defaultElementTypes.reference({}, 'child')
+ expect(rootType(result)).toBe('div')
+ })
+})
+
+// ---------------------------------------------------------------------------
+// jsonToReact (convenience wrapper)
+// ---------------------------------------------------------------------------
+
+describe('jsonToReact', () => {
+ it('renders a simple paragraph', () => {
+ const json = { type: 'doc', children: [{ type: 'p', children: [{ text: 'hello' }] }] }
+ const result = jsonToReact(json)
+ expect(rootType(result)).toBe('p')
+ expect(textContent(result)).toBe('hello')
+ })
+
+ it('applies text marks', () => {
+ const json = {
+ type: 'doc',
+ children: [{ type: 'p', children: [{ text: 'bold', bold: true }] }],
+ }
+ const result = jsonToReact(json)
+ expect(textContent(result)).toBe('bold')
+ // The should contain a
+ const pChildren = rootProps(result)?.children
+ expect(rootType(pChildren)).toBe('strong')
+ })
+
+ it('handles line breaks', () => {
+ const json = {
+ type: 'doc',
+ children: [{ type: 'p', children: [{ text: 'a\nb' }] }],
+ }
+ const result = jsonToReact(json)
+ // Should contain text "a", a , and text "b"
+ expect(textContent(result)).toBe('ab') // text-only content excludes
+ })
+
+ it('renders nested structure', () => {
+ const json = {
+ type: 'doc',
+ children: [
+ {
+ type: 'ul',
+ children: [
+ { type: 'li', children: [{ text: 'item1' }] },
+ { type: 'li', children: [{ text: 'item2' }] },
+ ],
+ },
+ ],
+ }
+ const result = jsonToReact(json)
+ expect(rootType(result)).toBe('ul')
+ expect(textContent(result)).toBe('item1item2')
+ })
+
+ it('returns null for empty doc', () => {
+ const json = { type: 'doc', children: [] }
+ expect(jsonToReact(json)).toBeNull()
+ })
+
+ it('supports customElementTypes override', () => {
+ const json = {
+ type: 'doc',
+ children: [{ type: 'p', children: [{ text: 'custom' }] }],
+ }
+ const result = jsonToReact(json, {
+ customElementTypes: {
+ p: (_, ch) => React.createElement('div', { className: 'custom-p' }, ch),
+ },
+ })
+ expect(rootType(result)).toBe('div')
+ expect(rootProps(result)?.className).toBe('custom-p')
+ })
+
+ it('supports customTextMarks override', () => {
+ const json = {
+ type: 'doc',
+ children: [{ type: 'p', children: [{ text: 'highlighted', bold: true }] }],
+ }
+ const result = jsonToReact(json, {
+ customTextMarks: {
+ bold: (ch) => React.createElement('b', { className: 'custom-bold' }, ch),
+ },
+ })
+ const pChild = rootProps(result)?.children
+ expect(rootType(pChild)).toBe('b')
+ expect(rootProps(pChild)?.className).toBe('custom-bold')
+ })
+
+ it('renders classname/id on text', () => {
+ const json = {
+ type: 'doc',
+ children: [
+ {
+ type: 'p',
+ children: [{ text: 'classed', classname: 'my-class', id: 'my-id' }],
+ },
+ ],
+ }
+ const result = jsonToReact(json)
+ const span = rootProps(result)?.children
+ expect(rootType(span)).toBe('span')
+ expect(rootProps(span)?.className).toBe('my-class')
+ expect(rootProps(span)?.id).toBe('my-id')
+ })
+
+ it('renders inline text styles', () => {
+ const json = {
+ type: 'doc',
+ children: [
+ {
+ type: 'p',
+ children: [{ text: 'red', attrs: { style: { color: 'red' } } }],
+ },
+ ],
+ }
+ const result = jsonToReact(json)
+ const span = rootProps(result)?.children
+ expect(rootType(span)).toBe('span')
+ expect(rootProps(span)?.style).toEqual({ color: 'red' })
+ })
+})
diff --git a/test/testingData.ts b/test/testingData.ts
index e09e519..6dfa25e 100644
--- a/test/testingData.ts
+++ b/test/testingData.ts
@@ -55,7 +55,7 @@ export const imageAssetData = {
],
},
]),
- expectedHtml: ` `,
+ expectedHtml: ` `,
},
caption: {
value: getDoc([
@@ -394,7 +394,7 @@ export const imageAssetData = {
],
},
]),
- expectedHtml: `BATMAN BATMAN BATMAN BATMAN `,
+ expectedHtml: `BATMAN BATMAN BATMAN BATMAN `,
},
"inline-base": {
value: getDoc([
@@ -494,7 +494,7 @@ export const imageAssetData = {
],
},
]),
- expectedHtml: `I am batman
`,
+ expectedHtml: `I am batman
`,
},
"inline-caption": {
value: getDoc([
@@ -756,7 +756,7 @@ export const imageAssetData = {
],
},
]),
- expectedHtml: `I amBATMAN batman `,
+ expectedHtml: `I amBATMAN batman `,
},
"inline-anchor-alignment-target-alt-caption": {
value: getDoc([
@@ -873,7 +873,7 @@ export const imageAssetData = {
],
},
]),
- expectedHtml: `BATMAN I am batmanBATMAN I am batman `,
+ expectedHtml: `BATMAN I am batmanBATMAN I am batman `,
},
};
diff --git a/test/toRedactor.test.ts b/test/toRedactor.test.ts
index ef9cbf1..c3f77f6 100644
--- a/test/toRedactor.test.ts
+++ b/test/toRedactor.test.ts
@@ -354,6 +354,60 @@ test("should convert codeblock to proper html, where \n should not be replaced w
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 = {
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 ``
+ },
+ },
+ })
+
+ expect(htmlValue).toBe("")
+ })
+
+ 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) => ``,
+ li: (_, ch) => `${ch ?? ''} `,
+ a: (jb, ch) => `${ch ?? ''} `,
+ img: (jb) => ` `,
+ hr: () => ' ',
+ code: (_, ch) => `${ch ?? ''} `,
+ table: (_, 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${tag}>
`)
+ }
+ })
+ })
+
+ 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('')
+ })
+
+ 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('')
+ })
+ })
+
+ 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
')
+ })
+ })
+})