import { describe, it } from 'node:test'; import assertStrict from 'node:assert/strict'; import { encodeSentinels, decodeSentinels } from '../src/richTextCodec.js'; import type { RichTextRoot } from '@unito/integration-api'; describe('encodeSentinels', () => { it('passes through when the predicate accepts every node', () => { const tree: RichTextRoot = { type: 'root', children: [{ type: 'paragraph', children: [{ type: 'text', value: 'hi' }] }], }; assertStrict.deepEqual( encodeSentinels(tree, () => true), tree, ); }); it('replaces unsupported nodes with paired markers, serializing data as space-separated key="value"', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: 'a' }, { type: 'jira_panel', data: { panelType: 'info', color: '#deebff' }, children: [{ type: 'text', value: 'x' }], }, { type: 'text', value: 'b' }, ], }, ], }; assertStrict.deepEqual( encodeSentinels(tree, node => node.type !== 'jira_panel'), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: 'a' }, { type: 'text', value: '【 jira_panel panelType="info" color="#deebff" 】' }, { type: 'text', value: 'x' }, { type: 'text', value: '【 end jira_panel 】' }, { type: 'text', value: 'b' }, ], }, ], }, ); }); it('emits a self-closing marker for childless unsupported nodes', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: 'see ' }, { type: 'asana_macro' }, { type: 'text', value: ', then ' }, { type: 'asana_macro', data: { kind: 'attachment', id: 42 } }, { type: 'text', value: '.' }, ], }, { type: 'thematicBreak' }, { type: 'paragraph', children: [{ type: 'text', value: 'after' }] }, ], }; assertStrict.deepEqual( encodeSentinels(tree, node => node.type !== 'asana_macro' && node.type !== 'thematicBreak'), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: 'see ' }, { type: 'text', value: '【 asana_macro / 】' }, { type: 'text', value: ', then ' }, { type: 'text', value: '【 asana_macro kind="attachment" id=42 / 】' }, { type: 'text', value: '.' }, ], }, { type: 'paragraph', children: [{ type: 'text', value: '【 thematicBreak / 】' }] }, { type: 'paragraph', children: [{ type: 'text', value: 'after' }] }, ], }, ); }); it('recursively encodes children inside an encoded parent (depth-first)', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'jira_panel', children: [{ type: 'asana_macro', children: [{ type: 'text', value: 'inside' }] }], }, ], }, ], }; assertStrict.deepEqual( encodeSentinels(tree, node => !['jira_panel', 'asana_macro'].includes(node.type)), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: '【 jira_panel 】' }, { type: 'text', value: '【 asana_macro 】' }, { type: 'text', value: 'inside' }, { type: 'text', value: '【 end asana_macro 】' }, { type: 'text', value: '【 end jira_panel 】' }, ], }, ], }, ); }); it('passes the ancestor chain to the predicate, innermost-first with root last', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [{ type: 'strong', children: [{ type: 'text', value: 'bold' }] }], }, ], }; const seen: string[][] = []; encodeSentinels(tree, (node, ancestors) => { seen.push([node.type, ...ancestors.map(a => a.type)]); return true; }); assertStrict.deepEqual( seen.find(s => s[0] === 'strong'), ['strong', 'paragraph', 'root'], ); }); it('places markers as inline text siblings when the parent holds inline content', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: 'a ' }, { type: 'strong', children: [{ type: 'text', value: 'bold' }] }, { type: 'text', value: ' b' }, ], }, ], }; assertStrict.deepEqual( encodeSentinels(tree, node => node.type !== 'strong'), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: 'a ' }, { type: 'text', value: '【 strong 】' }, { type: 'text', value: 'bold' }, { type: 'text', value: '【 end strong 】' }, { type: 'text', value: ' b' }, ], }, ], }, ); }); it('wraps each marker in its own paragraph when encoding under a block-content parent', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [{ type: 'text', value: 'before' }] }, { type: 'jira_panel', children: [{ type: 'paragraph', children: [{ type: 'text', value: 'inside' }] }], }, { type: 'paragraph', children: [{ type: 'text', value: 'after' }] }, ], }; assertStrict.deepEqual( encodeSentinels(tree, node => node.type !== 'jira_panel'), { type: 'root', children: [ { type: 'paragraph', children: [{ type: 'text', value: 'before' }] }, { type: 'paragraph', children: [{ type: 'text', value: '【 jira_panel 】' }] }, { type: 'paragraph', children: [{ type: 'text', value: 'inside' }] }, { type: 'paragraph', children: [{ type: 'text', value: '【 end jira_panel 】' }] }, { type: 'paragraph', children: [{ type: 'text', value: 'after' }] }, ], }, ); }); it('emits non-string primitive data values as JSON literals', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [{ type: 'checkbox', data: { checked: true, indent: 2 } }], }, ], }; assertStrict.deepEqual( encodeSentinels(tree, node => node.type !== 'checkbox'), { type: 'root', children: [ { type: 'paragraph', children: [{ type: 'text', value: '【 checkbox checked=true indent=2 / 】' }], }, ], }, ); }); it('drops individual data values that cannot survive the wire format, keeping the rest', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'jira_panel', data: { safe: 'ok', hasQuote: 'has "quote"', hasBracket: 'has 】 inside', nonPrimitive: { x: 1 }, }, children: [{ type: 'text', value: 'x' }], }, ], }, ], }; assertStrict.deepEqual( encodeSentinels(tree, node => node.type !== 'jira_panel'), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: '【 jira_panel safe="ok" 】' }, { type: 'text', value: 'x' }, { type: 'text', value: '【 end jira_panel 】' }, ], }, ], }, ); }); }); describe('encodeSentinels — table substitution', () => { it('renders an unsupported table as a single markdown paragraph with break-delimited rows', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'table', children: [ { type: 'tableRow', children: [ { type: 'tableCell', data: { header: true }, children: [{ type: 'text', value: 'Name' }] }, { type: 'tableCell', data: { header: true }, children: [{ type: 'text', value: 'Status' }] }, ], }, { type: 'tableRow', children: [ { type: 'tableCell', children: [{ type: 'text', value: 'Alice' }] }, { type: 'tableCell', children: [{ type: 'text', value: 'Active' }] }, ], }, ], }, ], }; assertStrict.deepEqual( encodeSentinels(tree, node => node.type !== 'table'), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: '| Name | Status |' }, { type: 'break' }, { type: 'text', value: '| --- | --- |' }, { type: 'break' }, { type: 'text', value: '| Alice | Active |' }, ], }, ], }, ); }); it('emits the header separator after row 1 unconditionally', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'table', children: [ { type: 'tableRow', children: [ { type: 'tableCell', children: [{ type: 'text', value: 'a' }] }, { type: 'tableCell', children: [{ type: 'text', value: 'b' }] }, ], }, ], }, ], }; assertStrict.deepEqual( encodeSentinels(tree, node => node.type !== 'table'), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: '| a | b |' }, { type: 'break' }, { type: 'text', value: '| --- | --- |' }, ], }, ], }, ); }); it('preserves supported wrappers as canonical nodes inside cells', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'table', children: [ { type: 'tableRow', children: [ { type: 'tableCell', children: [ { type: 'link', data: { url: 'https://x' }, children: [{ type: 'text', value: 'click' }] }, ], }, { type: 'tableCell', children: [{ type: 'text', value: 'Status' }] }, ], }, ], }, ], }; assertStrict.deepEqual( encodeSentinels(tree, node => node.type !== 'table'), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: '| ' }, { type: 'link', data: { url: 'https://x' }, children: [{ type: 'text', value: 'click' }] }, { type: 'text', value: ' | Status |' }, { type: 'break' }, { type: 'text', value: '| --- | --- |' }, ], }, ], }, ); }); it('escapes backslashes, pipes, and newlines in cell text', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'table', children: [ { type: 'tableRow', children: [ { type: 'tableCell', children: [{ type: 'text', value: 'a | b' }] }, { type: 'tableCell', children: [{ type: 'text', value: 'line1\nline2' }] }, { type: 'tableCell', children: [{ type: 'text', value: 'back\\slash' }] }, ], }, ], }, ], }; assertStrict.deepEqual( encodeSentinels(tree, node => node.type !== 'table'), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: '| a \\| b | line1 line2 | back\\\\slash |' }, { type: 'break' }, { type: 'text', value: '| --- | --- | --- |' }, ], }, ], }, ); }); it('drops empty tables', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'table', children: [] }, { type: 'paragraph', children: [{ type: 'text', value: 'after' }] }, ], }; assertStrict.deepEqual( encodeSentinels(tree, node => node.type !== 'table'), { type: 'root', children: [{ type: 'paragraph', children: [{ type: 'text', value: 'after' }] }], }, ); }); it('preserves unsupported nodes in cells via sentinel markers', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'table', children: [ { type: 'tableRow', children: [ { type: 'tableCell', children: [ { type: 'image', data: { src: 'thumb.png' } }, { type: 'text', value: ' ' }, { type: 'mention', data: { id: '123' }, children: [{ type: 'text', value: '@alice' }] }, ], }, ], }, ], }, ], }; assertStrict.deepEqual( encodeSentinels(tree, node => node.type !== 'table' && node.type !== 'image' && node.type !== 'mention'), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: '| 【 image src="thumb.png" / 】 【 mention id="123" 】@alice【 end mention 】 |', }, { type: 'break' }, { type: 'text', value: '| --- |' }, ], }, ], }, ); }); it('keeps the table when supported', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'table', children: [ { type: 'tableRow', children: [{ type: 'tableCell', children: [{ type: 'text', value: 'x' }] }], }, ], }, ], }; assertStrict.deepEqual( encodeSentinels(tree, () => true), tree, ); }); }); describe('decodeSentinels', () => { it('passes through when no markers are present', () => { const tree: RichTextRoot = { type: 'root', children: [{ type: 'paragraph', children: [{ type: 'text', value: 'hi' }] }], }; assertStrict.deepEqual(decodeSentinels(tree), tree); }); it('reconstructs paired markers, with and without attrs', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: '【 jira_panel 】' }, { type: 'text', value: 'plain' }, { type: 'text', value: '【 end jira_panel 】' }, { type: 'text', value: ' ' }, { type: 'text', value: '【 jira_panel panelType="info" color="#deebff" 】' }, { type: 'text', value: 'with attrs' }, { type: 'text', value: '【 end jira_panel 】' }, ], }, ], }; assertStrict.deepEqual(decodeSentinels(tree), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'jira_panel', children: [{ type: 'text', value: 'plain' }] }, { type: 'text', value: ' ' }, { type: 'jira_panel', data: { panelType: 'info', color: '#deebff' }, children: [{ type: 'text', value: 'with attrs' }], }, ], }, ], }); }); it('reconstructs nested markers innermost-first', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: '【 jira_panel 】' }, { type: 'text', value: '【 asana_macro 】' }, { type: 'text', value: 'inside' }, { type: 'text', value: '【 end asana_macro 】' }, { type: 'text', value: '【 end jira_panel 】' }, ], }, ], }; assertStrict.deepEqual(decodeSentinels(tree), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'jira_panel', children: [{ type: 'asana_macro', children: [{ type: 'text', value: 'inside' }] }], }, ], }, ], }); }); it('merges open markers fragmented across adjacent text nodes', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: '【 jira_' }, { type: 'text', value: 'panel 】' }, { type: 'text', value: 'inside' }, { type: 'text', value: '【 end jira_panel 】' }, ], }, ], }; assertStrict.deepEqual(decodeSentinels(tree), { type: 'root', children: [ { type: 'paragraph', children: [{ type: 'jira_panel', children: [{ type: 'text', value: 'inside' }] }], }, ], }); }); it('reconstructs across paragraph siblings when open and close land in different blocks', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [{ type: 'text', value: '【 jira_panel 】' }] }, { type: 'paragraph', children: [{ type: 'text', value: 'inside' }] }, { type: 'paragraph', children: [{ type: 'text', value: '【 end jira_panel 】' }] }, ], }; assertStrict.deepEqual(decodeSentinels(tree), { type: 'root', children: [ { type: 'jira_panel', children: [{ type: 'paragraph', children: [{ type: 'text', value: 'inside' }] }], }, ], }); }); it('leaves an orphan open marker as plain text', () => { const tree: RichTextRoot = { type: 'root', children: [{ type: 'paragraph', children: [{ type: 'text', value: '【 jira_panel 】unmatched' }] }], }; assertStrict.deepEqual(decodeSentinels(tree), tree); }); it('leaves an orphan close marker as plain text', () => { const tree: RichTextRoot = { type: 'root', children: [{ type: 'paragraph', children: [{ type: 'text', value: 'unmatched 【 end jira_panel 】' }] }], }; assertStrict.deepEqual(decodeSentinels(tree), tree); }); it('splits a text node when markers wrap inline content mid-text', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [{ type: 'text', value: 'before 【 strong 】middle【 end strong 】 after' }], }, ], }; assertStrict.deepEqual(decodeSentinels(tree), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: 'before ' }, { type: 'strong', children: [{ type: 'text', value: 'middle' }] }, { type: 'text', value: ' after' }, ], }, ], }); }); it('handles multiple paired and cross-sibling sentinels in one tree', () => { const tree: RichTextRoot = { type: 'root', children: [ // Multiple paired sentinels packed into a single text node. { type: 'paragraph', children: [{ type: 'text', value: '【 strong 】a【 end strong 】 and 【 emphasis 】b【 end emphasis 】' }], }, // Cross-sibling outer (jira_panel) wrapping a paragraph that itself contains a paired inner (strong). { type: 'paragraph', children: [{ type: 'text', value: '【 jira_panel 】' }] }, { type: 'paragraph', children: [{ type: 'text', value: '【 strong 】Hello【 end strong 】 world' }], }, { type: 'paragraph', children: [{ type: 'text', value: '【 end jira_panel 】' }] }, // Back-to-back cross-sibling sentinels. { type: 'paragraph', children: [{ type: 'text', value: '【 asana_macro 】' }] }, { type: 'paragraph', children: [{ type: 'text', value: 'macro' }] }, { type: 'paragraph', children: [{ type: 'text', value: '【 end asana_macro 】' }] }, { type: 'paragraph', children: [{ type: 'text', value: '【 notion_block 】' }] }, { type: 'paragraph', children: [{ type: 'text', value: 'block' }] }, { type: 'paragraph', children: [{ type: 'text', value: '【 end notion_block 】' }] }, ], }; assertStrict.deepEqual(decodeSentinels(tree), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'strong', children: [{ type: 'text', value: 'a' }] }, { type: 'text', value: ' and ' }, { type: 'emphasis', children: [{ type: 'text', value: 'b' }] }, ], }, { type: 'jira_panel', children: [ { type: 'paragraph', children: [ { type: 'strong', children: [{ type: 'text', value: 'Hello' }] }, { type: 'text', value: ' world' }, ], }, ], }, { type: 'asana_macro', children: [{ type: 'paragraph', children: [{ type: 'text', value: 'macro' }] }], }, { type: 'notion_block', children: [{ type: 'paragraph', children: [{ type: 'text', value: 'block' }] }], }, ], }); }); it('decodes legacy production forms — positional args and pre-RFC tag spellings', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: '【 highlight #ff0000 】' }, { type: 'text', value: 'hot1' }, { type: 'text', value: '【 end highlight 】' }, { type: 'text', value: ' ' }, { type: 'text', value: '【 highlighted #00ff00 】' }, { type: 'text', value: 'hot2' }, { type: 'text', value: '【 end highlighted 】' }, { type: 'text', value: ' ' }, { type: 'text', value: '【 header 2 】' }, { type: 'text', value: 'Title' }, { type: 'text', value: '【 end header 】' }, { type: 'text', value: ' ' }, { type: 'text', value: '【 code python 】' }, { type: 'text', value: 'print("hi")' }, { type: 'text', value: '【 end code 】' }, ], }, ], }; assertStrict.deepEqual(decodeSentinels(tree), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'highlight', data: { color: '#ff0000' }, children: [{ type: 'text', value: 'hot1' }], }, { type: 'text', value: ' ' }, { type: 'highlight', data: { color: '#00ff00' }, children: [{ type: 'text', value: 'hot2' }], }, { type: 'text', value: ' ' }, { type: 'heading2', children: [{ type: 'text', value: 'Title' }] }, { type: 'text', value: ' ' }, { type: 'code', data: { lang: 'python' }, children: [{ type: 'text', value: 'print("hi")' }], }, ], }, ], }); }); it('decodes self-closing markers with varied spacing around the slash', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: '【 a/ 】' }, { type: 'text', value: ' ' }, { type: 'text', value: '【 b /】' }, { type: 'text', value: ' ' }, { type: 'text', value: '【 c/】' }, { type: 'text', value: ' ' }, { type: 'text', value: '【 d kind="x"/ 】' }, ], }, ], }; assertStrict.deepEqual(decodeSentinels(tree), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'a' }, { type: 'text', value: ' ' }, { type: 'b' }, { type: 'text', value: ' ' }, { type: 'c' }, { type: 'text', value: ' ' }, { type: 'd', data: { kind: 'x' } }, ], }, ], }); }); it('decodes nested same-tag sentinels by tracking marker depth', () => { const tree: RichTextRoot = { type: 'root', children: [ // Inline: outer foo and inner foo packed into one paragraph's text. { type: 'paragraph', children: [{ type: 'text', value: '【 foo 】outer 【 foo 】inner【 end foo 】 more【 end foo 】' }], }, // Cross-paragraph: outer bar's open and close in different paragraph // siblings, with an inner bar pair inside the middle paragraph. { type: 'paragraph', children: [{ type: 'text', value: '【 bar 】' }] }, { type: 'paragraph', children: [{ type: 'text', value: '【 bar 】content【 end bar 】' }], }, { type: 'paragraph', children: [{ type: 'text', value: '【 end bar 】' }] }, ], }; assertStrict.deepEqual(decodeSentinels(tree), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'foo', children: [ { type: 'text', value: 'outer ' }, { type: 'foo', children: [{ type: 'text', value: 'inner' }] }, { type: 'text', value: ' more' }, ], }, ], }, { type: 'bar', children: [ { type: 'paragraph', children: [{ type: 'bar', children: [{ type: 'text', value: 'content' }] }], }, ], }, ], }); }); }); describe('decodeSentinels — markdown table reconstruction', () => { it('reconstructs a markdown table from a single text node with newline-delimited rows', () => { const tree: RichTextRoot = { type: 'root', children: [{ type: 'text', value: '| Name | Status |\n| --- | --- |\n| Alice | Active |' }], }; assertStrict.deepEqual(decodeSentinels(tree), { type: 'root', children: [ { type: 'table', children: [ { type: 'tableRow', children: [ { type: 'tableCell', data: { header: true }, children: [{ type: 'text', value: 'Name' }] }, { type: 'tableCell', data: { header: true }, children: [{ type: 'text', value: 'Status' }] }, ], }, { type: 'tableRow', children: [ { type: 'tableCell', children: [{ type: 'text', value: 'Alice' }] }, { type: 'tableCell', children: [{ type: 'text', value: 'Active' }] }, ], }, ], }, ], }); }); it('reconstructs a markdown table from break-delimited inline content', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'text', value: '| Name | Status |' }, { type: 'break' }, { type: 'text', value: '| --- | --- |' }, { type: 'break' }, { type: 'text', value: '| Alice | Active |' }, ], }; const result = decodeSentinels(tree); assertStrict.equal(result.children[0]?.type, 'table'); assertStrict.equal(result.children[0]?.children?.length, 2); }); it('unwraps a paragraph wrapping the table content', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [{ type: 'text', value: '| Name | Status |\n| --- | --- |\n| Alice | Active |' }], }, ], }; const result = decodeSentinels(tree); assertStrict.equal(result.children[0]?.type, 'table'); }); it('recovers cell content with supported wrappers preserved as canonical nodes', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'text', value: '| ' }, { type: 'link', data: { url: 'https://x' }, children: [{ type: 'text', value: 'click' }] }, { type: 'text', value: ' | Status |\n| --- | --- |' }, ], }; assertStrict.deepEqual(decodeSentinels(tree), { type: 'root', children: [ { type: 'table', children: [ { type: 'tableRow', children: [ { type: 'tableCell', data: { header: true }, children: [ { type: 'link', data: { url: 'https://x' }, children: [{ type: 'text', value: 'click' }] }, ], }, { type: 'tableCell', data: { header: true }, children: [{ type: 'text', value: 'Status' }], }, ], }, ], }, ], }); }); it('unescapes pipes and backslashes in cell text', () => { const tree: RichTextRoot = { type: 'root', children: [{ type: 'text', value: '| a \\| b | back\\\\slash |\n| --- | --- |' }], }; const root = decodeSentinels(tree); const cells = (root.children[0]?.children?.[0]?.children ?? []) as Array<{ children?: Array<{ value?: string }> }>; assertStrict.equal(cells[0]?.children?.[0]?.value, 'a | b'); assertStrict.equal(cells[1]?.children?.[0]?.value, 'back\\slash'); }); it('rejects when the separator is not exactly ---', () => { for (const value of ['| h |\n| -- |', '| h |\n| ---- |', '| h |\n| :---: |']) { const tree: RichTextRoot = { type: 'root', children: [{ type: 'text', value }] }; assertStrict.deepEqual(decodeSentinels(tree), tree); } }); it('rejects column-count mismatches', () => { for (const value of ['| a | b |\n| --- |', '| a |\n| --- |\n| x | y |']) { const tree: RichTextRoot = { type: 'root', children: [{ type: 'text', value }] }; assertStrict.deepEqual(decodeSentinels(tree), tree); } }); it('leaves regular text alone', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'text', value: 'Hello | world' }, { type: 'text', value: '| Note: see above |' }, ], }; assertStrict.deepEqual(decodeSentinels(tree), tree); }); it('recurses into block containers', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'blockquote', children: [{ type: 'text', value: '| a | b |\n| --- | --- |' }], }, ], }; assertStrict.deepEqual(decodeSentinels(tree), { type: 'root', children: [ { type: 'blockquote', children: [ { type: 'table', children: [ { type: 'tableRow', children: [ { type: 'tableCell', data: { header: true }, children: [{ type: 'text', value: 'a' }] }, { type: 'tableCell', data: { header: true }, children: [{ type: 'text', value: 'b' }] }, ], }, ], }, ], }, ], }); }); }); describe('encodeSentinels + decodeSentinels round-trip', () => { it('preserves childless unsupported nodes via the self-closing marker form', () => { const original: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: 'see ' }, { type: 'asana_macro', data: { kind: 'attachment', id: 42 } }, { type: 'text', value: '.' }, ], }, { type: 'thematicBreak' }, ], }; assertStrict.deepEqual( decodeSentinels(encodeSentinels(original, node => node.type !== 'asana_macro' && node.type !== 'thematicBreak')), { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: 'see ' }, { type: 'asana_macro', data: { kind: 'attachment', id: 42 } }, { type: 'text', value: '.' }, ], }, { type: 'paragraph', children: [{ type: 'thematicBreak' }] }, ], }, ); }); it('preserves typed primitive data values (boolean, number) — not just stringified copies', () => { const original: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'tableCell', data: { header: true, indent: 2, kind: 'summary' }, children: [{ type: 'text', value: 'cell' }], }, ], }, ], }; assertStrict.deepEqual(decodeSentinels(encodeSentinels(original, node => node.type !== 'tableCell')), original); }); it('decodes a quoted-string attribute as a string even when the contents read like a JSON literal', () => { const tree: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: '【 checkbox checked="true" indent="2" 】' }, { type: 'text', value: '【 end checkbox 】' }, ], }, ], }; assertStrict.deepEqual(decodeSentinels(tree), { type: 'root', children: [ { type: 'paragraph', children: [{ type: 'checkbox', data: { checked: 'true', indent: '2' } }], }, ], }); }); it('preserves same-tag nesting (custom node containing itself)', () => { const original: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'jira_panel', children: [ { type: 'text', value: 'outer ' }, { type: 'jira_panel', children: [{ type: 'text', value: 'inner' }] }, { type: 'text', value: ' more' }, ], }, ], }, ], }; assertStrict.deepEqual(decodeSentinels(encodeSentinels(original, node => node.type !== 'jira_panel')), original); }); it('preserves supported children verbatim inside an encoded parent (block-context placement)', () => { const original: RichTextRoot = { type: 'root', children: [ { type: 'jira_panel', children: [ { type: 'paragraph', children: [ { type: 'text', value: 'a ' }, { type: 'strong', children: [{ type: 'text', value: 'bold' }] }, { type: 'text', value: ' b' }, ], }, ], }, ], }; assertStrict.deepEqual(decodeSentinels(encodeSentinels(original, node => node.type !== 'jira_panel')), original); }); it('preserves a tree mixing custom nodes with data, childless nodes, nested rejection, and block-context placement', () => { const original: RichTextRoot = { type: 'root', children: [ { type: 'paragraph', children: [ { type: 'text', value: 'before ' }, { type: 'image', data: { src: 'https://example.com/x.png', alt: 'x' } }, { type: 'text', value: ' after' }, ], }, { type: 'jira_panel', data: { panelType: 'info' }, children: [ { type: 'paragraph', children: [ { type: 'asana_macro', data: { id: '42' }, children: [{ type: 'text', value: 'nested' }], }, ], }, ], }, ], }; assertStrict.deepEqual( decodeSentinels(encodeSentinels(original, node => !['image', 'jira_panel', 'asana_macro'].includes(node.type))), original, ); }); it('preserves nested unsupported block nodes — each marker pair wrapped at block level, no bare text at block scope', () => { const original: RichTextRoot = { type: 'root', children: [ { type: 'jira_panel', children: [ { type: 'asana_panel', children: [{ type: 'paragraph', children: [{ type: 'text', value: 'x' }] }], }, ], }, ], }; const reject = (node: { type: string }): boolean => !['jira_panel', 'asana_panel'].includes(node.type); assertStrict.deepEqual(encodeSentinels(original, reject), { type: 'root', children: [ { type: 'paragraph', children: [{ type: 'text', value: '【 jira_panel 】' }] }, { type: 'paragraph', children: [{ type: 'text', value: '【 asana_panel 】' }] }, { type: 'paragraph', children: [{ type: 'text', value: 'x' }] }, { type: 'paragraph', children: [{ type: 'text', value: '【 end asana_panel 】' }] }, { type: 'paragraph', children: [{ type: 'text', value: '【 end jira_panel 】' }] }, ], }); assertStrict.deepEqual(decodeSentinels(encodeSentinels(original, reject)), original); }); it('preserves nested unsupported block nodes inside a non-root block container (blockquote)', () => { const original: RichTextRoot = { type: 'root', children: [ { type: 'blockquote', children: [ { type: 'jira_panel', children: [ { type: 'asana_panel', children: [{ type: 'paragraph', children: [{ type: 'text', value: 'x' }] }], }, ], }, ], }, ], }; const reject = (node: { type: string }): boolean => !['jira_panel', 'asana_panel'].includes(node.type); assertStrict.deepEqual(encodeSentinels(original, reject), { type: 'root', children: [ { type: 'blockquote', children: [ { type: 'paragraph', children: [{ type: 'text', value: '【 jira_panel 】' }] }, { type: 'paragraph', children: [{ type: 'text', value: '【 asana_panel 】' }] }, { type: 'paragraph', children: [{ type: 'text', value: 'x' }] }, { type: 'paragraph', children: [{ type: 'text', value: '【 end asana_panel 】' }] }, { type: 'paragraph', children: [{ type: 'text', value: '【 end jira_panel 】' }] }, ], }, ], }); assertStrict.deepEqual(decodeSentinels(encodeSentinels(original, reject)), original); }); it('round-trips a markdown-encoded table whose first row declares headers', () => { const original: RichTextRoot = { type: 'root', children: [ { type: 'table', children: [ { type: 'tableRow', children: [ { type: 'tableCell', data: { header: true }, children: [{ type: 'text', value: 'Name' }] }, { type: 'tableCell', data: { header: true }, children: [{ type: 'text', value: 'Status' }] }, ], }, { type: 'tableRow', children: [ { type: 'tableCell', children: [{ type: 'text', value: 'Alice' }] }, { type: 'tableCell', children: [{ type: 'text', value: 'Active' }] }, ], }, ], }, ], }; assertStrict.deepEqual(decodeSentinels(encodeSentinels(original, node => node.type !== 'table')), original); }); it('round-trips a table whose cells contain unsupported types', () => { const original: RichTextRoot = { type: 'root', children: [ { type: 'table', children: [ { type: 'tableRow', children: [ { type: 'tableCell', data: { header: true }, children: [ { type: 'image', data: { src: 'thumb.png' } }, { type: 'text', value: ' ' }, { type: 'mention', data: { id: '123' }, children: [{ type: 'text', value: '@alice' }] }, ], }, ], }, ], }, ], }; const reject = (node: { type: string }) => node.type !== 'table' && node.type !== 'image' && node.type !== 'mention'; assertStrict.deepEqual(decodeSentinels(encodeSentinels(original, reject)), original); }); it('round-trips a table whose cells contain a supported wrapper (link)', () => { const original: RichTextRoot = { type: 'root', children: [ { type: 'table', children: [ { type: 'tableRow', children: [ { type: 'tableCell', data: { header: true }, children: [ { type: 'link', data: { url: 'https://x' }, children: [{ type: 'text', value: 'click' }] }, ], }, { type: 'tableCell', data: { header: true }, children: [{ type: 'text', value: 'Status' }], }, ], }, ], }, ], }; assertStrict.deepEqual(decodeSentinels(encodeSentinels(original, node => node.type !== 'table')), original); }); });