import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import type { ParseError, Result } from '../../result.ts'
import { adfToHtml, htmlToAdf } from '../../index.ts'
const ids = {
item: '019ad5af-efd9-7f4e-a7c9-6aef16d4945f',
list: '019d8e77-7df8-7a83-91ea-43a7ca2aae3b',
mention: '0199f4c2-8b1e-7d03-9a6f-2c5e8d71b4a0',
paragraph: '01957ad9-e3f7-70aa-a5f3-4c013274f488',
quote: '019d2fe4-3e67-7b76-b40a-a776a8f76aea',
}
function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
}
function paragraph(...content: AdfNode[]): AdfNode {
return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' }
}
function heading(level: number, ...content: AdfNode[]): AdfNode {
return content.length === 0 ? { attrs: { level }, type: 'heading' } : { attrs: { level }, content, type: 'heading' }
}
function text(value: string, ...marks: AdfMark[]): AdfNode {
return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' }
}
function item(...content: AdfNode[]): AdfNode {
return { content, type: 'listItem' }
}
function read(result: Result Read the manual. Read the manual: a < b && c > "d" \'e\'\tf\ng\fh < b b Bolt Bolt x Bolt Quoted Twice Check the collation. Ask ${carry('span', mention, '@Mikael')} ${carry('span', text('a\u0085b', { type: 'underline' }), 'ab').replace('\u0085', '\\u0085')} x a${carry('span', text('b'), 'b')} x Bolt Bolt a a a a b \n', 'unmappable-html at /content/0 line 1 offset 0'],
[' \n', 'unsupported-node-shape at /content/0/content/0 line 1 offset 0'],
['\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
[' \n', 'unsupported-node-shape at /content/0/content/0 line 1 offset 0'],
[' a a Check the collation. Ask ${carry('span', mention, '')} a a x x x a a b a ab a it's a > b a\rb a a \n', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
[' ab a a &bogus; b a', 'unmappable-html at /content/0/content/0 line 1 offset 0'],
[' a', 'unmappable-html at /content/0 line 1 offset 0'],
],
}
for (const [group, cases] of Object.entries(refusals)) {
for (const [html, expected] of cases) test(`${group}: ${JSON.stringify(html)}`, () => assert.equal(refusal(htmlToAdf(html)), expected))
}
const notStrings: Readonly x ab a > bLevel 1
\n
\nLevel 6
\n')), document(heading(1, text('Level 1')), paragraph(text('Read the manual.')), { type: 'rule' }, heading(6, text('Level 6'))))
assert.deepEqual(read(htmlToAdf('\n\n')), document(paragraph(), heading(2)))
})
test('reads text\'s marks from its elements, the outermost first, and each run of text as one text node', () => {
const link: AdfMark = { attrs: { href: 'https://example.com/a?b=1&c=2', title: 'The "A" page' }, type: 'link' }
const html = 'npm ci or this
Line two\n
\n')), document({ content: [item(paragraph(text('Bolt'))), item(paragraph(text('Nut')))], type: 'bulletList' }))
const listed: AdfNode = { attrs: { order: 3 }, content: [item(paragraph(text('Bolt')), { content: [item(paragraph(text('M8')))], type: 'bulletList' }), item(paragraph(text('a')), paragraph(text('b')))], type: 'orderedList' }
assert.deepEqual(read(htmlToAdf('\n
\n')), document(listed))
assert.deepEqual(read(htmlToAdf('\n
\n\n
\n')), document({ content: [item({ type: 'rule' }), { type: 'listItem' }], type: 'orderedList' }))
assert.deepEqual(read(htmlToAdf('
\n\n
\n')), document({ content: [item(paragraph())], type: 'bulletList' }))
assert.deepEqual(read(htmlToAdf('\n
\n')), document({ attrs: { order: -2 }, content: [item(paragraph(text('x')))], type: 'orderedList' }))
})
test('reads the text ending an item\'s line up to the line feed before the item\'s next block', () => {
const listed = (...blocks: AdfNode[]): AdfDocument => document({ content: [item(...blocks)], type: 'bulletList' })
assert.deepEqual(read(htmlToAdf('\n
\n')), listed(paragraph(text('a\n')), paragraph(text('b'))))
assert.deepEqual(read(htmlToAdf('\n
\n')), listed(paragraph({ type: 'hardBreak' }), { type: 'rule' }))
assert.deepEqual(read(htmlToAdf('
\n
\n\n
\n')), listed(paragraph(text('\n'))))
assert.deepEqual(read(htmlToAdf('\n
\n')), listed(paragraph(text('\n'), text('a', { type: 'em' }))))
})
test('reads an item\'s first paragraph holding an attribute as its element on the item\'s line', () => {
const listed = (...blocks: AdfNode[]): AdfDocument => document({ content: [item(...blocks)], type: 'bulletList' })
const held: AdfNode = { attrs: { localId: ids.paragraph }, content: [text('Bolt')], type: 'paragraph' }
assert.deepEqual(read(htmlToAdf(`\n
\n`)), listed(held))
assert.deepEqual(read(htmlToAdf(`\n
\n`)), listed(held, { content: [item(paragraph(text('M8')))], type: 'bulletList' }))
assert.deepEqual(read(htmlToAdf(`\n
\n\n
\n`)), listed({ attrs: { localId: ids.paragraph }, type: 'paragraph' }))
})
test('reads data-* attributes as the attributes they name, a value that is no string from its JSON', () => {
const localId = { localId: ids.paragraph }
assert.deepEqual(read(htmlToAdf(`x
\n`)), document({ attrs: { level: 2, ...localId }, content: [text('x')], type: 'heading' }))
assert.deepEqual(read(htmlToAdf(`\n
\n`)), document({ attrs: { localId: ids.quote }, content: [paragraph(text('x'))], type: 'blockquote' }))
const listed: AdfNode = { attrs: { localId: ids.list, order: 3 }, content: [{ attrs: { localId: ids.item }, content: [{ attrs: localId, content: [text('Bolt')], type: 'paragraph' }], type: 'listItem' }], type: 'orderedList' }
assert.deepEqual(read(htmlToAdf(`\n
\n`)), document(listed))
const code: AdfNode = { attrs: { hideLineNumbers: false, language: 'js', wrap: true, ...localId }, content: [text('x')], type: 'codeBlock' }
assert.deepEqual(read(htmlToAdf(`
\n`)), document(code))
assert.deepEqual(read(htmlToAdf('x
\n')), document({ attrs: { color: '#ccc', style: 'a"b', weight: -0 }, type: 'rule' }))
})
test('reads quotes, code blocks and containers holding no content key', () => {
assert.deepEqual(read(htmlToAdf('\n
\n')), document({ content: [paragraph(text('Quoted')), { content: [paragraph(text('Twice'))], type: 'blockquote' }], type: 'blockquote' }))
assert.deepEqual(read(htmlToAdf('\n
\n
\n')), document({ attrs: { language: 'javascript' }, content: [text('if (a < b)\n run()')], type: 'codeBlock' }))
assert.deepEqual(read(htmlToAdf('if (a < b)\n run()
\nplain
\n')), document({ content: [text('plain')], type: 'codeBlock' }, { type: 'codeBlock' }))
assert.deepEqual(read(htmlToAdf('\n\n
\n')), document({ type: 'blockquote' }, { type: 'bulletList' }, { type: 'orderedList' }))
})
test('reads an image standing alone in a paragraph, a link around it where one marks it', () => {
const image = (media: AdfNode): AdfNode => ({ attrs: { layout: 'center' }, content: [media], type: 'mediaSingle' })
assert.deepEqual(read(htmlToAdf('

\n
\n`)), document({ content: [item(paragraph(text('x')))], type: 'bulletList' }))
})
// Each group's cases, the HTML and its refusal.
const refusals: Readonly\n
\n', 'unmappable-html at /content/0/content/0/content/0 line 2 offset 5'],
['\n
\n', 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10'],
[`\n
\n`, 'unmappable-html at /content/0/content/0/content/0 line 3 offset 10'],
['\n
\n', 'unmappable-html at /content/0/content/0 line 2 offset 5'],
['\n
\n', 'unmappable-html at /content/0/content/0 line 2 offset 5'],
[`\n
\n`, 'unmappable-html at /content/0/content/0 line 2 offset 5'],
['\n
\n', 'unmappable-html at /content/0/content/0 line 2 offset 5'],
['\n
\n', 'unmappable-html at /content/0/content/0 line 2 offset 5'],
['
\n', 'unmappable-html at /content/0 line 1 offset 0'],
['\n
\n', 'unmappable-html at /content/0 line 2 offset 13'],
['\n
\n', 'unmappable-html at /content/0 line 1 offset 0'],
['\n
\n', 'unmappable-html at /content/0 line 1 offset 0'],
['\n
\n', 'unmappable-html at /content/0 line 1 offset 0'],
['\n
\n', 'unmappable-html at /content/0 line 1 offset 0'],
['\n
\n', 'unmappable-html at /content/0 line 1 offset 0'],
['\n
\n', 'unmappable-html at /content/0 line 1 offset 0'],
['
\n', 'unmappable-html at /content/0 line 1 offset 0'],
['x
\n', 'unmappable-html at /content/0 line 1 offset 0'],
['x
\n', 'unmappable-html at /content/0 line 1 offset 0'],
['xx
\n', 'unmappable-html at /content/0 line 1 offset 0'],
['
\n', 'unmappable-html at /content/0 line 1 offset 0'],
['a &bogus; b
\n`, 'unmappable-html at /content/0 line 1 offset 0'],
[`\n`, 'unmappable-html at /content/0 line 1 offset 0'],
[`\n`, 'unmappable-html at /content/0 line 1 offset 0'],
[`x
\n`, 'unmappable-html at / line 1 offset 0'],
[`${documentCarry.replace('>
\n`, 'unsupported-node-shape at /content/0 line 1 offset 0'],
[`
\n${documentCarry}\n`, 'unsupported-node-shape at /content/1 line 2 offset 5'],
[`\n${documentCarry}\n
\n`, 'unsupported-node-shape at /content/0/content/0 line 2 offset 13'],
[`${carry('div', { type: 'doc', version: 2 }, '')}\n`, 'unsupported-node-shape at /content/0 line 1 offset 0'],
],
'refuses a data-* attribute out of the order adfToHtml writes as unmappable-html, and one no attribute of its node spells as unsupported-node-shape': [
['
\n', 'unmappable-html at /content/0 line 1 offset 0'],
['
\n', 'unmappable-html at /content/0 line 1 offset 0'],
['a
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
['\n
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
['
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
['x
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
['x
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
['x
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
['x
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
['x
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
['
\n', 'unsupported-node-shape at /content/0 line 1 offset 0'],
['\n
\n', 'unsupported-node-shape at /content/0/content/0 line 2 offset 5'],
[`\n`, 'unsupported-node-shape at /content/0 line 1 offset 0'],
],
'refuses HTML adfToHtml does not write as unmappable-html, at the start of the line it stands on': [
['
b
\nd\n', 'unmappable-html at /content/2 line 4 offset 37'],
],
'refuses an image adfToHtml would not write': [
['b\ncb
a \u00a0
\n', 'text escapes &, <, > and " as &, <, > and ", and holds no character HTML cannot hold: found " \\u00a0\\n"'], ['a: found "x\n', ' as written here is no code element of the dialect, which writes or : found "\na\n', 'an item\'s line ends at , or at a line feed before the item\'s next block, and its text spells < as <: found "\\n"'],
['\n- ab
\n
\n', ' as written here is no inline element of the dialect: found "b\\n\\n"'],
['\n- if a < b
\n
\n', 'an item\'s line ends at , or at a line feed before the item\'s next block, and its text spells < as <: found "< b\\n\\n"'],
['\n- see x
\n
\n', ' holds its attributes as name="value", each value escaped as text is, then >: found "&b=2\\">x\\n\\n"'],
['x
\n', ' as written here is no mark element of the dialect: found "x\\n"'],
['\n', ' holds its attributes as name="value", each value escaped as text is, then >: found "&b=2\\">x"…'],
[`a
\n${carry('div', panel, 'Check the collation.
')}\n`, 'a carry element shows its node\'s text as adfToHtml writes it, then ends at ; an edit to it is no edit to the node: found "collation. assert.equal(message(htmlToAdf(html)), expected))
test('reads blocks nested 500 levels, and refuses the next level, a list\'s items one level below it', () => {
const quotes = (levels: number): string => `${'\n'.repeat(levels)}x
${'\n
'.repeat(levels)}\n`
assert.equal(refusal(htmlToAdf(quotes(500))), 'read')
assert.equal(refusal(htmlToAdf(quotes(501))).startsWith(`unsupported-nesting-depth at /${'content/0/'.repeat(501)}content/0 line ${502}`), true)
const lists = (levels: number): string => `${'\n- x\n'.repeat(levels)}
${'\n \n
'.repeat(levels)}\n`
assert.equal(refusal(htmlToAdf(lists(500))), 'read')
assert.equal(refusal(htmlToAdf(lists(501))).startsWith('unsupported-nesting-depth'), true)
assert.equal(refusal(htmlToAdf('\n'.repeat(1_000_000))).startsWith('unsupported-nesting-depth'), true)
})
test('refuses a carried node whose JSON nests past the levels its depth leaves, and an inline carry past 500', () => {
const carried = carry('div', { attrs: { a: nested(1, 498, (inner) => [inner]) }, type: 'unknownBlock' }, '')
assert.equal(refusal(htmlToAdf(`${carried}\n`)), 'read')
assert.equal(refusal(htmlToAdf(`\n${carried}\n
\n`)), 'unsupported-nesting-depth at /content/0/content/0 line 2 offset 13')
const inline = nested({ text: 'x', type: 'text' }, 500, (inner) => ({ content: [inner], type: 'unknownInline' }))
assert.equal(refusal(htmlToAdf(`${carry('span', inline, '')}
\n`)), 'unsupported-nesting-depth at /content/0/content/0 line 1 offset 0')
})
// The documents adfToHtml's own tests write, by what each holds.
const written: Readonly> = {
'a panel holding a code block': document({ attrs: { panelType: 'info' }, content: [paragraph(text('Run this:')), { attrs: { language: 'python' }, content: [text('if ready:\n deploy()')], type: 'codeBlock' }], type: 'panel' }),
'adjacent unmarked text': document(paragraph(text('a'), text('b'), text('c', { type: 'em' }), text('d'), text('e'))),
'no content key': { type: 'doc', version: 1 },
}
for (const [holding, adf] of Object.entries(written)) {
test(`reads back what adfToHtml writes for a document holding ${holding}`, () => {
const html = adfToHtml(adf)
assert.ok(html.ok)
assert.deepEqual(read(htmlToAdf(html.value)), adf)
})
}