3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Answer stability round 1: 0 🔴, 10 🟡 (0 misread); pin section tags, cell values and refusal paths with tests, parametrize test loops, drop the markdown tryRule's dead clause

This commit is contained in:
2026-11-05 11:51:50 +01:00
committed by Lillem4n
parent a0f4b45578
commit d7c6a6b1f5
9 changed files with 112 additions and 82 deletions
+39 -31
View File
@@ -152,40 +152,48 @@ test('refuses an inline card a link marks, wherever the document holds it and wh
}
})
test('refuses what Atlassian\'s schema gives no node, wherever the document holds it and whichever call writes it', () => {
const shapes: { message: string; node: AdfNode }[] = [
{ message: 'a panel carries no text: this one holds text', node: { text: 'x', type: 'panel' } },
{ message: 'a paragraph carries no text: this one holds text', node: { text: 'x', type: 'paragraph' } },
{ message: 'a media holds no content: this one holds some', node: { content: [{ type: 'paragraph' }], type: 'media' } },
{ message: 'a rule holds no content: this one holds some', node: { content: [{ type: 'paragraph' }], type: 'rule' } },
{ message: 'a status node holds neither content nor text: this one holds content', node: { content: [{ text: 'x', type: 'text' }], type: 'status' } },
{ message: 'a status node holds neither content nor text: this one holds text', node: { text: 'x', type: 'status' } },
{ message: 'a hardBreak node holds neither content nor text: this one holds text', node: { text: 'x', type: 'hardBreak' } },
]
for (const { message, node } of shapes) {
const holders = [
{ content: [node], type: 'doc' },
{ content: [{ content: [node], type: 'paragraph' }], type: 'doc' },
{ content: [{ content: [node], type: 'futureBlock' }], type: 'doc' },
]
const paths = [['content', 0], ['content', 0, 'content', 0], ['content', 0, 'content', 0]]
for (const [index, { content }] of holders.entries()) {
for (const write of [adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown]) {
const refused = write({ content, type: 'doc', version: 1 })
assert.deepEqual(refused.ok ? 'written' : refused.error, { code: 'unsupported-node-shape', message, path: paths[index] })
}
const shapeWriters = [adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown]
const schemaRefusals: readonly { message: string; node: AdfNode }[] = [
{ message: 'a node of type panel holds no text key: this one holds one', node: { text: 'x', type: 'panel' } },
{ message: 'a node of type paragraph holds no text key: this one holds one', node: { text: 'x', type: 'paragraph' } },
{ message: 'a node of type media holds no content: this one holds some', node: { content: [{ type: 'paragraph' }], type: 'media' } },
{ message: 'a node of type rule holds no content: this one holds some', node: { content: [{ type: 'paragraph' }], type: 'rule' } },
{ message: 'a node of type status holds neither content nor text: this one holds content', node: { content: [{ text: 'x', type: 'text' }], type: 'status' } },
{ message: 'a node of type status holds neither content nor text: this one holds text', node: { text: 'x', type: 'status' } },
{ message: 'a node of type hardBreak holds neither content nor text: this one holds text', node: { text: 'x', type: 'hardBreak' } },
]
const schemaRefusalHolders: readonly { hold: (node: AdfNode) => AdfNode[]; path: (number | string)[]; where: string }[] = [
{ hold: (node) => [node], path: ['content', 0], where: 'at the top' },
{ hold: (node) => [{ content: [node], type: 'paragraph' }], path: ['content', 0, 'content', 0], where: 'in a known node' },
{ hold: (node) => [{ content: [node], type: 'futureBlock' }], path: ['content', 0, 'content', 0], where: 'in a carried node' },
]
for (const { message, node } of schemaRefusals) {
for (const { hold, path, where } of schemaRefusalHolders) {
for (const write of shapeWriters) {
test(`${write.name} refuses what Atlassian's schema forbids ${where}: ${message}`, () => {
const refused = write({ content: hold(node), type: 'doc', version: 1 })
assert.deepEqual(refused.ok ? 'written' : refused.error, { code: 'unsupported-node-shape', message, path })
})
}
}
})
}
test('writes what the schema refusals leave to the carry', () => {
const carried: AdfNode[] = [
{ content: [], type: 'rule' },
{ content: [{ text: 'x', type: 'text' }], text: 'y', type: 'futureBlock' },
{ content: [{ content: [{ type: 'paragraph' }], type: 'text' }], type: 'paragraph' },
]
for (const node of carried) for (const write of [adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown]) assert.equal(write({ content: [node], type: 'doc', version: 1 }).ok, true)
})
const carriedShapes: readonly AdfNode[] = [
{ content: [], type: 'rule' },
{ content: [{ text: 'x', type: 'text' }], text: 'y', type: 'futureBlock' },
{ content: [{ content: [{ type: 'paragraph' }], type: 'text' }], type: 'paragraph' },
]
for (const node of carriedShapes) {
for (const write of shapeWriters) {
test(`${write.name} writes what the schema refusals leave to the carry: ${JSON.stringify(node)}`, () => {
assert.equal(write({ content: [node], type: 'doc', version: 1 }).ok, true)
})
}
}
test('names the first link-marked card in document order', () => {
const card = (url: string): AdfNode => ({ attrs: { url }, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'inlineCard' })
+4 -4
View File
@@ -170,13 +170,13 @@ function shapeRefusal(node: AdfNode): string | undefined {
if (node.type === 'inlineCard' && nodeMarks(node).some(({ type }) => type === 'link')) return 'an inlineCard takes no link mark: this one holds one'
const holdsContent = nodeContent(node).length > 0
if (inlineNodeModel(node.type) !== undefined) {
if (holdsContent) return `a ${node.type} node holds neither content nor text: this one holds content`
return node.text === undefined ? undefined : `a ${node.type} node holds neither content nor text: this one holds text`
if (holdsContent) return `a node of type ${node.type} holds neither content nor text: this one holds content`
return node.text === undefined ? undefined : `a node of type ${node.type} holds neither content nor text: this one holds text`
}
const model = blockNodeModel(node.type)
if (model === undefined) return undefined
if (node.text !== undefined) return `a ${node.type} carries no text: this one holds text`
return model.contentModel === 'none' && holdsContent ? `a ${node.type} holds no content: this one holds some` : undefined
if (node.text !== undefined) return `a node of type ${node.type} holds no text key: this one holds one`
return model.contentModel === 'none' && holdsContent ? `a node of type ${node.type} holds no content: this one holds some` : undefined
}
// Tracks ancestors alone: a tree may share an object, but an object holding itself walks the guards forever.
+2 -3
View File
@@ -18,14 +18,13 @@ export function isHeaderRow(row: { content?: AdfNode[] }): boolean {
return cells.length > 0 && cells.every(({ type }) => type === 'tableHeader')
}
// A cell's attributes in the order `readCellAttributes` takes them; `undefined` where one spells nothing its element writes, so the whole table rides the carry.
// A cell's attributes in the order `readCellAttributes` takes them; `undefined` where a value has no spelling on the element, so the whole table rides the carry.
export function cellAttributes(cell: AdfNode): string | undefined {
const { background, colspan, colwidth, rowspan } = nodeAttrs(cell)
const own = [spanAttribute('colspan', colspan), spanAttribute('rowspan', rowspan), styleAttribute(background), widthsAttribute(colwidth), dataAttributes(cell, blockNodes.tableCell.attributes, elementWritten)]
return own.every((attribute) => attribute !== undefined) ? own.join('') : undefined
}
// `style` and `colspan`, `rowspan` and `data-colwidth` stand first, each once, in the order `cellAttributes` writes them.
export function readCellAttributes(pairs: readonly (readonly [string, string])[], element: string): Read<AdfAttributes> {
const attrs: AdfAttributes = {}
let rest = pairs
@@ -38,7 +37,7 @@ export function readCellAttributes(pairs: readonly (readonly [string, string])[]
rest = rest.slice(1)
}
const misplaced = rest.find(([name]) => leading.includes(name))
if (misplaced !== undefined) return { fault: { code: 'unmappable-html', message: `<${element}> takes colspan, rowspan, style and data-colwidth first, each once and in that order: found ${misplaced[0]} after them` } }
if (misplaced !== undefined) return { fault: { code: 'unmappable-html', message: `<${element}> takes colspan, rowspan, style and data-colwidth first, each once and in that order: found ${misplaced[0]} out of place` } }
const data = readDataAttributes(rest, element, blockNodes.tableCell.attributes, elementWritten)
return data.fault === undefined ? { value: { ...attrs, ...data.value } } : data
}
+15 -3
View File
@@ -325,12 +325,17 @@ test('writes a table\'s attributes as the writer panel picked them', () => {
assert.equal(html(adfToHtml(document({ content: [{ content: [{ attrs: { background: 'red', colwidth: [0.5] }, type: 'tableHeader' }], type: 'tableRow' }], type: 'table' }))), '<table>\n<thead>\n<tr><th style="background-color:red" data-colwidth="0.5"></th></tr>\n</thead>\n</table>\n')
})
const carriedCellAttrs: readonly AdfAttributes[] = [{ background: 'url(x)' }, { background: 'red;color:blue' }, { background: '' }, { background: 7 }, { colspan: 0 }, { colspan: 1.5 }, { colspan: '2' }, { rowspan: -1 }, { colwidth: [] }, { colwidth: ['1'] }, { colwidth: 120 }, { valign: 7 }, { size: 'big' }]
for (const attrs of carriedCellAttrs) {
test(`carries the whole table where a cell holds ${JSON.stringify(attrs)}`, () => {
assert.equal(carriedBlock({ content: [{ content: [{ attrs, content: [paragraph(text('x'))], type: 'tableCell' }], type: 'tableRow' }], type: 'table' }), true)
})
}
test('carries the whole table where it, a row or a cell holds what its element cannot', () => {
const cell = (attrs: AdfAttributes): AdfNode => ({ attrs, content: [paragraph(text('x'))], type: 'tableCell' })
const inRow = (node: AdfNode): AdfNode => ({ content: [{ content: [node], type: 'tableRow' }], type: 'table' })
for (const attrs of [{ background: 'url(x)' }, { background: 'red;color:blue' }, { background: '' }, { background: 7 }, { colspan: 0 }, { colspan: 1.5 }, { colspan: '2' }, { rowspan: -1 }, { colwidth: [] }, { colwidth: ['1'] }, { colwidth: 120 }, { valign: 7 }, { size: 'big' }]) {
assert.equal(carriedBlock(inRow(cell(attrs))), true, JSON.stringify(attrs))
}
assert.equal(carriedBlock({ content: [paragraph(text('x'))], type: 'table' }), true)
assert.equal(carriedBlock(inRow(paragraph(text('x')))), true)
assert.equal(carriedBlock({ content: [], type: 'table' }), true)
@@ -356,6 +361,13 @@ test('counts a table and its rows each a level, and a cell none, as a list and i
assert.equal(refusal(adfToHtml(holding(499, table({ content: [cell], type: 'tableRow' })))).startsWith('unsupported-nesting-depth'), true)
})
test('names the path of a row or a cell it refuses', () => {
const holding = (levels: number, inner: AdfNode): AdfDocument => document(nested('blockquote', levels, inner))
const cell: AdfNode = { content: [paragraph(text('x'))], type: 'tableCell' }
assert.equal(refusal(adfToHtml(holding(500, { content: [{ type: 'tableRow' }], type: 'table' }))), `unsupported-nesting-depth at ${'/content/0'.repeat(502)}`)
assert.equal(refusal(adfToHtml(holding(499, { content: [{ type: 'tableRow' }, { content: [cell], type: 'tableRow' }], type: 'table' }))), `unsupported-nesting-depth at ${'/content/0'.repeat(500)}/content/1/content/0`)
})
test('carries a node holding what its element cannot', () => {
assert.equal(carriedBlock({ attrs: { localId: 7 }, content: [text('x')], type: 'paragraph' }), true)
assert.equal(carriedBlock({ attrs: { layout: 'wide' }, content: [text('x')], type: 'paragraph' }), true)
+15 -6
View File
@@ -189,9 +189,9 @@ test('refuses a carry element holding an inline card a link marks, at any depth
test('refuses a carry element holding a shape every writer refuses, at any depth in the node it carries', () => {
const shapes = [
{ message: 'a panel carries no text: this one holds text', node: { text: 'x', type: 'panel' } },
{ message: 'a rule holds no content: this one holds some', node: { content: [{ type: 'paragraph' }], type: 'rule' } },
{ message: 'a status node holds neither content nor text: this one holds text', node: { text: 'x', type: 'status' } },
{ message: 'a node of type panel holds no text key: this one holds one', node: { text: 'x', type: 'panel' } },
{ message: 'a node of type rule holds no content: this one holds some', node: { content: [{ type: 'paragraph' }], type: 'rule' } },
{ message: 'a node of type status holds neither content nor text: this one holds text', node: { text: 'x', type: 'status' } },
]
for (const { message: refusal, node } of shapes) {
for (const carried of [node, { content: [node], type: 'futureBlock' }]) {
@@ -247,6 +247,11 @@ const tableRefusals: readonly (readonly [string, string])[] = [
['<table data-size="big"></table>\n', 'unsupported-node-shape'],
['<table>\n<tbody>\n<tr data-size="big"></tr>\n</tbody>\n</table>\n', 'unsupported-node-shape'],
['<table>\n<tbody>\n<tr><td data-size="big"></td></tr>\n</tbody>\n</table>\n', 'unsupported-node-shape'],
['<table>\n<tfoot>\n<tr></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
['<table>\n<thead>\n<tr><th>a</th></tr>\n</tfoot>\n<tbody>\n<tr></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
['<table>\n<tbody>\n<tr><td colspan="2x">a</td></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
['<table>\n<tbody>\n<tr><td style="color:red;background-color:red">a</td></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
['<table>\n<tbody>\n<tr><td>a<hr>\n</td></tr>\n</tbody>\n</table>\n', 'unmappable-html'],
]
for (const [html, code] of tableRefusals) {
@@ -255,6 +260,10 @@ for (const [html, code] of tableRefusals) {
})
}
test('refuses a character between a list item\'s tag and its first block', () => {
assert.equal(refusal(htmlToAdf('<ul>\n<li>a<hr>\n</li>\n</ul>\n')).split(' ')[0], 'unmappable-html')
})
test('counts a table and its rows each a level, and a cell none, as adfToHtml does', () => {
const holding = (levels: number, inner: string): string => `${'<blockquote>\n'.repeat(levels)}${inner}${'\n</blockquote>'.repeat(levels)}\n`
const row = '<table>\n<tbody>\n<tr></tr>\n</tbody>\n</table>'
@@ -504,9 +513,9 @@ const messages: readonly (readonly [string, string])[] = [
['<div class="adf-carry" data-adf="{ &quot;type&quot;:&quot;x&quot;}"></div>\n', 'a carry element spells its node\'s JSON canonically: compact, keys sorted, each character HTML cannot hold spelled as a JSON escape: found "{ &quot;type&quot;:&quot"…'],
[`${documentCarry}\n<hr>\n`, 'a carry element holding the document stands alone, as the whole document: found "\\n<hr>\\n"'],
[`<hr>\n${documentCarry}\n`, 'a carry element holding the document stands alone, as the whole document: found "<div class=\\"adf-carry\\" d"…'],
['<ul>\n<li><p>Bolt</p></li>\n</ul>\n', 'the first paragraph of an item or cell holding content and no attribute stands bare on its line, as in <li>Bolt</li>: found "<p>Bolt</p></li>\\n</ul>\\n"'],
['<ul>\n<li><p></p></li>\n</ul>\n', 'the first paragraph of an item or cell holding neither content nor an attribute stands on a line of its own: found "<p></p></li>\\n</ul>\\n"'],
['<ul>\n<li>\n<p data-local-id="x"></p>\n</li>\n</ul>\n', 'the first paragraph of an item or cell holding an attribute stands as its <p> on its line, straight after <li> or <td>: found "<p data-local-id=\\"x\\"></p"…'],
['<ul>\n<li><p>Bolt</p></li>\n</ul>\n', 'an item\'s or cell\'s first paragraph holding content and no attribute stands bare on the line of the item\'s or cell\'s start tag, as in <li>Bolt</li>: found "<p>Bolt</p></li>\\n</ul>\\n"'],
['<ul>\n<li><p></p></li>\n</ul>\n', 'an item\'s or cell\'s first paragraph holding neither content nor an attribute stands on a line of its own: found "<p></p></li>\\n</ul>\\n"'],
['<ul>\n<li>\n<p data-local-id="x"></p>\n</li>\n</ul>\n', 'an item\'s or cell\'s first paragraph holding an attribute stands as its <p> straight after the item\'s or cell\'s start tag: found "<p data-local-id=\\"x\\"></p"…'],
['<p><img src="a">b</p>\n', 'an image stands alone in its paragraph as <img src="…" alt="…">, alt left out where it holds none, inside <a> where a link marks it: found "b</p>\\n"'],
['<p><img src="a" alt=""></p>\n', 'an image stands alone in its paragraph as <img src="…" alt="…">, alt left out where it holds none, inside <a> where a link marks it: found "<img src=\\"a\\" alt=\\"\\"></p>"…'],
['<ol start="03">\n<li>a</li>\n</ol>\n', 'an ordered list starts at an integer, written as JavaScript writes it: found "03\\">\\n<li>a</li>\\n</ol>\\n"'],
+8 -8
View File
@@ -28,13 +28,10 @@ type BlockReader = (html: string, tag: StartTag, place: Place, open: Open) => Re
type BlockStep = { end: number; kind: 'node'; node: AdfNode } | { end: number; kind: 'opened'; open: Open }
// A container whose blocks, items, rows or cells are being read: `node` holds what its opening tag gave, `depth` is its children's as `adfToHtml` counts it, and `closer` ends it. A table's rows are `rows` and its closer turns from `headCloser` to `bodyCloser` at `</thead>`.
// A container whose blocks, items, rows or cells are being read: `node` holds what its opening tag gave, `depth` is its children's as `adfToHtml` counts it, and `closer` ends it. An open table has kind `rows`, and its closer turns from `headCloser` to `bodyCloser` at `</thead>`.
type Open = { closer: string; content: AdfNode[]; depth: number; kind: 'blocks' | 'item' | 'list' | 'row' | 'rows'; node: AdfNode; place: Place | undefined }
// No element name opens both a block and an inline node, so the name alone tells an item's line from its next block.
const bodyCloser = '\n</tbody>\n</table>'
const headCloser = '\n</thead>\n</table>'
const headToBody = '</thead>\n<tbody>\n'
const blockReaders: ReadonlyMap<string, BlockReader> = new Map([
[containerElements.blockquote, containerReader('blockquote')],
[containerElements.bulletList, containerReader('bulletList')],
@@ -46,15 +43,18 @@ const blockReaders: ReadonlyMap<string, BlockReader> = new Map([
['table', readTable],
...headingElements.map((name): [string, BlockReader] => [name, readHeading]),
])
const bodyCloser = '\n</tbody>\n</table>'
const codeCloser = '</code></pre>'
const documentCarryAlone = 'a carry element holding the document stands alone, as the whole document'
const documentCarryEnd = `${carryContent({ type: 'doc' }, 'div')}</div>`
const emptyDocument = serializeCanonicalJson({ type: 'doc', version: 1 }, 'compact')
const formRefusals: Readonly<Record<FirstParagraphForm, string>> = {
bare: "the first paragraph of an item or cell holding content and no attribute stands bare on its line, as in <li>Bolt</li>",
block: 'the first paragraph of an item or cell holding neither content nor an attribute stands on a line of its own',
element: 'the first paragraph of an item or cell holding an attribute stands as its <p> on its line, straight after <li> or <td>',
bare: "an item's or cell's first paragraph holding content and no attribute stands bare on the line of the item's or cell's start tag, as in <li>Bolt</li>",
block: "an item's or cell's first paragraph holding neither content nor an attribute stands on a line of its own",
element: "an item's or cell's first paragraph holding an attribute stands as its <p> straight after the item's or cell's start tag",
}
const headCloser = '\n</thead>\n</table>'
const headToBody = '</thead>\n<tbody>\n'
// Reads back what `adfToHtml` writes, as it or a browser spells it.
export function htmlToAdf(html: string): Result<AdfDocument, ParseError> {
@@ -280,7 +280,7 @@ function readRow(html: string, index: number, place: Place, table: Open): Result
}
function firstRowRefusal<T>(html: string, index: number, place: Place | undefined): Result<T, ParseError> {
return refused('unmappable-html', `a table's first row stands in <thead> where every cell is a <th> and it holds one, and in <tbody> otherwise: ${foundAt(html, index)}`, html, index, place)
return refused('unmappable-html', `a table's first row stands in <thead> where it holds at least one cell and all its cells are <th>, and in <tbody> otherwise: ${foundAt(html, index)}`, html, index, place)
}
function readCell(html: string, index: number, place: Place, depth: number): Result<BlockStep, ParseError> {
+3 -3
View File
@@ -756,8 +756,8 @@ test('spells colwidth as its widths, comma-separated, and carries a cell whose c
})
test('refuses the content a directive body has no room for', () => {
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [paragraph()], type: 'media' }))), 'unsupported-node-shape: a media holds no content: this one holds some')
assert.equal(markdown(adfToLosslessMarkdown(document({ text: 'x', type: 'panel' }))), 'unsupported-node-shape: a panel carries no text: this one holds text')
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [paragraph()], type: 'media' }))), 'unsupported-node-shape: a node of type media holds no content: this one holds some')
assert.equal(markdown(adfToLosslessMarkdown(document({ text: 'x', type: 'panel' }))), 'unsupported-node-shape: a node of type panel holds no text key: this one holds one')
})
test('separates blocks in a container body by a blank line only where a directive line is not separation already', () => {
@@ -882,7 +882,7 @@ test('carries an inline node attribute no section spells', () => {
test('refuses the content and slot an inline directive has no room for', () => {
const refused = (node: AdfNode): string => markdown(adfToLosslessMarkdown(document(paragraph(node, { text: 'y', type: 'text' }))))
const neither = (type: string, held: string): string => `unsupported-node-shape: a ${type} node holds neither content nor text: this one holds ${held}`
const neither = (type: string, held: string): string => `unsupported-node-shape: a node of type ${type} holds neither content nor text: this one holds ${held}`
assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'status' }), neither('status', 'content'))
assert.equal(refused({ text: 'x', type: 'status' }), neither('status', 'text'))
assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'hardBreak' }), neither('hardBreak', 'content'))
+1 -1
View File
@@ -324,6 +324,6 @@ function tryParagraph(node: AdfNode, path: ConvertErrorPath, flavour: WrittenFla
}
function tryRule(node: AdfNode): string | undefined {
if (!holdsOnlyAttributes(node, []) || nodeContent(node).length > 0) return undefined
if (!holdsOnlyAttributes(node, [])) return undefined
return '---'
}
+25 -23
View File
@@ -105,26 +105,28 @@ test('refuses a value that is not a string, in every reader, at the start of the
}
})
test('reads each shape a flavour claims as the flavour of the reader it reaches', () => {
const highlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
const withoutIds = (result: Result<AdfDocument>): AdfNode[] | string => JSON.parse(JSON.stringify(content(result)), (key, value: unknown) => (key === 'localId' || (key === 'attrs' && JSON.stringify(value) === '{}') ? undefined : value))
const shapes: [string, AdfNode[], AdfNode[], AdfNode[]][] = [
['!adf:panel info\nx\n!adf:/panel\n', [paragraph('!adf:panel info x !adf:/panel')], [{ attrs: { panelType: 'info' }, content: [paragraph('x')], type: 'panel' }], [paragraph('!adf:panel info x !adf:/panel')]],
['a!adf:textBreak{}a\n', [paragraph('a!adf:textBreak{}a')], [{ content: [text('a'), text('a')], type: 'paragraph' }], [paragraph('a!adf:textBreak{}a')]],
['!adf:doc {content=none}\n', [paragraph('!adf:doc {content=none}')], [], [paragraph('!adf:doc {content=none}')]],
['| a |\n| --- |\n| b |\n', [paragraph('| a | | --- | | b |')], [table(row(cell('tableHeader', text('a'))), row(cell('tableCell', text('b'))))], [table(row(cell('tableHeader', text('a'))), row(cell('tableCell', text('b'))))]],
['~~x~~\n', [paragraph('~~x~~')], [{ content: [marked('x', strike)], type: 'paragraph' }], [{ content: [marked('x', strike)], type: 'paragraph' }]],
['==x==\n', [paragraph('==x==')], [paragraph('==x==')], [{ content: [marked('x', highlight)], type: 'paragraph' }]],
['> [!NOTE]\n> x\n', [quote(paragraph('[!NOTE] x'))], [quote(paragraph('[!NOTE] x'))], [{ attrs: { panelType: 'info' }, content: [paragraph('x')], type: 'panel' }]],
['- [x] a\n', [bulletList(item(paragraph('[x] a')))], [bulletList(item(paragraph('[x] a')))], [{ content: [{ attrs: { state: 'DONE' }, content: [text('a')], type: 'taskItem' }], type: 'taskList' }]],
['```adf:futureBlock\n{}\n```\n', [{ attrs: { language: 'adf:futureBlock' }, content: [text('{}')], type: 'codeBlock' }], [{ type: 'futureBlock' }], [{ attrs: { language: 'adf:futureBlock' }, content: [text('{}')], type: 'codeBlock' }]],
]
for (const [markdown, commonMark, lossless, portable] of shapes) {
assert.deepEqual(content(commonMarkToAdf(markdown)), commonMark, `commonMarkToAdf ${JSON.stringify(markdown)}`)
assert.deepEqual(content(losslessMarkdownToAdf(markdown)), lossless, `losslessMarkdownToAdf ${JSON.stringify(markdown)}`)
assert.deepEqual(withoutIds(portableMarkdownToAdf(markdown)), portable, `portableMarkdownToAdf ${JSON.stringify(markdown)}`)
}
})
const highlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
const flavourShapes: readonly [string, AdfNode[], AdfNode[], AdfNode[]][] = [
['!adf:panel info\nx\n!adf:/panel\n', [paragraph('!adf:panel info x !adf:/panel')], [{ attrs: { panelType: 'info' }, content: [paragraph('x')], type: 'panel' }], [paragraph('!adf:panel info x !adf:/panel')]],
['a!adf:textBreak{}a\n', [paragraph('a!adf:textBreak{}a')], [{ content: [text('a'), text('a')], type: 'paragraph' }], [paragraph('a!adf:textBreak{}a')]],
['!adf:doc {content=none}\n', [paragraph('!adf:doc {content=none}')], [], [paragraph('!adf:doc {content=none}')]],
['| a |\n| --- |\n| b |\n', [paragraph('| a | | --- | | b |')], [table(row(cell('tableHeader', text('a'))), row(cell('tableCell', text('b'))))], [table(row(cell('tableHeader', text('a'))), row(cell('tableCell', text('b'))))]],
['~~x~~\n', [paragraph('~~x~~')], [{ content: [marked('x', strike)], type: 'paragraph' }], [{ content: [marked('x', strike)], type: 'paragraph' }]],
['==x==\n', [paragraph('==x==')], [paragraph('==x==')], [{ content: [marked('x', highlight)], type: 'paragraph' }]],
['> [!NOTE]\n> x\n', [quote(paragraph('[!NOTE] x'))], [quote(paragraph('[!NOTE] x'))], [{ attrs: { panelType: 'info' }, content: [paragraph('x')], type: 'panel' }]],
['- [x] a\n', [bulletList(item(paragraph('[x] a')))], [bulletList(item(paragraph('[x] a')))], [{ content: [{ attrs: { state: 'DONE' }, content: [text('a')], type: 'taskItem' }], type: 'taskList' }]],
['```adf:futureBlock\n{}\n```\n', [{ attrs: { language: 'adf:futureBlock' }, content: [text('{}')], type: 'codeBlock' }], [{ type: 'futureBlock' }], [{ attrs: { language: 'adf:futureBlock' }, content: [text('{}')], type: 'codeBlock' }]],
]
for (const [markdown, commonMark, lossless, portable] of flavourShapes) {
test(`reads a shape a flavour claims as the flavour of the reader it reaches: ${JSON.stringify(markdown)}`, () => {
const withoutIds = (result: Result<AdfDocument>): AdfNode[] | string => JSON.parse(JSON.stringify(content(result)), (key, value: unknown) => (key === 'localId' || (key === 'attrs' && JSON.stringify(value) === '{}') ? undefined : value))
assert.deepEqual(content(commonMarkToAdf(markdown)), commonMark)
assert.deepEqual(content(losslessMarkdownToAdf(markdown)), lossless)
assert.deepEqual(withoutIds(portableMarkdownToAdf(markdown)), portable)
})
}
test('builds an empty document from input holding no block, and one holding no content key from the doc directive alone', () => {
assert.deepEqual(losslessMarkdownToAdf(''), { ok: true, value: { content: [], type: 'doc', version: 1 } })
@@ -502,9 +504,9 @@ test('refuses a carry holding a shape every writer refuses, whether it stands al
const refusal = (markdown: string): string => String(content(losslessMarkdownToAdf(markdown))).replace(/; .*$/, '')
const status = { text: 'x', type: 'status' }
const json = JSON.stringify(status).replaceAll('"', '\\"')
assert.equal(refusal(`!adf:carry{json="${json}"}\n`), `${refused}a status node holds neither content nor text: this one holds text`)
assert.equal(refusal(`\`\`\`adf:futureBlock\n${JSON.stringify({ content: [status] }, null, 2)}\n\`\`\`\n`), `${refused}a status node holds neither content nor text: this one holds text`)
assert.equal(refusal(`\`\`\`adf:panel\n${JSON.stringify({ text: 'x' }, null, 2)}\n\`\`\`\n`), `${refused}a panel carries no text: this one holds text`)
assert.equal(refusal(`!adf:carry{json="${json}"}\n`), `${refused}a node of type status holds neither content nor text: this one holds text`)
assert.equal(refusal(`\`\`\`adf:futureBlock\n${JSON.stringify({ content: [status] }, null, 2)}\n\`\`\`\n`), `${refused}a node of type status holds neither content nor text: this one holds text`)
assert.equal(refusal(`\`\`\`adf:panel\n${JSON.stringify({ text: 'x' }, null, 2)}\n\`\`\`\n`), `${refused}a node of type panel holds no text key: this one holds one`)
})
test('names the shape the inline carry reads alone', () => {