From d7c6a6b1f57f051624dbd712c3b0590fe9b22bae Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 5 Nov 2026 11:51:50 +0100 Subject: [PATCH] =?UTF-8?q?Answer=20stability=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=2010=20=F0=9F=9F=A1=20(0=20misread);=20pin=20sect?= =?UTF-8?q?ion=20tags,=20cell=20values=20and=20refusal=20paths=20with=20te?= =?UTF-8?q?sts,=20parametrize=20test=20loops,=20drop=20the=20markdown=20tr?= =?UTF-8?q?yRule's=20dead=20clause?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adf/document.test.ts | 70 ++++++++++++---------- src/adf/document.ts | 8 +-- src/html/cell-element.ts | 5 +- src/html/emit/adf-to-html.test.ts | 18 +++++- src/html/parse/html-to-adf.test.ts | 21 +++++-- src/html/parse/html-to-adf.ts | 16 ++--- src/markdown/emit/adf-to-markdown.test.ts | 6 +- src/markdown/emit/adf-to-markdown.ts | 2 +- src/markdown/parse/markdown-to-adf.test.ts | 48 ++++++++------- 9 files changed, 112 insertions(+), 82 deletions(-) diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index 88e6c4a..0c0843e 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -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' }) diff --git a/src/adf/document.ts b/src/adf/document.ts index 903b63f..161a4d3 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -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. diff --git a/src/html/cell-element.ts b/src/html/cell-element.ts index 94f3078..0511924 100644 --- a/src/html/cell-element.ts +++ b/src/html/cell-element.ts @@ -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 { 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 } diff --git a/src/html/emit/adf-to-html.test.ts b/src/html/emit/adf-to-html.test.ts index cfbe700..f8bfe62 100644 --- a/src/html/emit/adf-to-html.test.ts +++ b/src/html/emit/adf-to-html.test.ts @@ -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' }))), '\n\n\n\n
\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) diff --git a/src/html/parse/html-to-adf.test.ts b/src/html/parse/html-to-adf.test.ts index 2185660..5860c1b 100644 --- a/src/html/parse/html-to-adf.test.ts +++ b/src/html/parse/html-to-adf.test.ts @@ -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])[] = [ ['
\n', 'unsupported-node-shape'], ['\n\n\n\n
\n', 'unsupported-node-shape'], ['\n\n\n\n
\n', 'unsupported-node-shape'], + ['\n\n\n\n
\n', 'unmappable-html'], + ['\n\n\n\n\n\n\n
a
\n', 'unmappable-html'], + ['\n\n\n\n
a
\n', 'unmappable-html'], + ['\n\n\n\n
a
\n', 'unmappable-html'], + ['\n\n\n\n
a
\n
\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('\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 => `${'
\n'.repeat(levels)}${inner}${'\n
'.repeat(levels)}\n` const row = '\n\n\n\n
' @@ -504,9 +513,9 @@ const messages: readonly (readonly [string, string])[] = [ ['
\n', 'a carry element spells its node\'s JSON canonically: compact, keys sorted, each character HTML cannot hold spelled as a JSON escape: found "{ "type":""…'], [`${documentCarry}\n
\n`, 'a carry element holding the document stands alone, as the whole document: found "\\n
\\n"'], [`
\n${documentCarry}\n`, 'a carry element holding the document stands alone, as the whole document: found "
\n
  • Bolt

  • \n\n', 'the first paragraph of an item or cell holding content and no attribute stands bare on its line, as in
  • Bolt
  • : found "

    Bolt

    \\n\\n"'], - ['
      \n
    • \n
    \n', 'the first paragraph of an item or cell holding neither content nor an attribute stands on a line of its own: found "

    \\n\\n"'], - ['
      \n
    • \n

      \n
    • \n
    \n', 'the first paragraph of an item or cell holding an attribute stands as its

    on its line, straight after

  • or : found "

    \n

  • Bolt

  • \n\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
  • Bolt
  • : found "

    Bolt

    \\n\\n"'], + ['
      \n
    • \n
    \n', 'an item\'s or cell\'s first paragraph holding neither content nor an attribute stands on a line of its own: found "

    \\n\\n"'], + ['
      \n
    • \n

      \n
    • \n
    \n', 'an item\'s or cell\'s first paragraph holding an attribute stands as its

    straight after the item\'s or cell\'s start tag: found "

    b

    \n', 'an image stands alone in its paragraph as …, alt left out where it holds none, inside where a link marks it: found "b

    \\n"'], ['

    \n', 'an image stands alone in its paragraph as …, alt left out where it holds none, inside
    where a link marks it: found "\\"\\"

    "…'], ['
      \n
    1. a
    2. \n
    \n', 'an ordered list starts at an integer, written as JavaScript writes it: found "03\\">\\n
  • a
  • \\n\\n"'], diff --git a/src/html/parse/html-to-adf.ts b/src/html/parse/html-to-adf.ts index 3a4d9e4..c506b12 100644 --- a/src/html/parse/html-to-adf.ts +++ b/src/html/parse/html-to-adf.ts @@ -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 ``. +// 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 ``. 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\n' -const headCloser = '\n\n' -const headToBody = '\n\n' const blockReaders: ReadonlyMap = new Map([ [containerElements.blockquote, containerReader('blockquote')], [containerElements.bulletList, containerReader('bulletList')], @@ -46,15 +43,18 @@ const blockReaders: ReadonlyMap = new Map([ ['table', readTable], ...headingElements.map((name): [string, BlockReader] => [name, readHeading]), ]) +const bodyCloser = '\n\n' const codeCloser = '' const documentCarryAlone = 'a carry element holding the document stands alone, as the whole document' const documentCarryEnd = `${carryContent({ type: 'doc' }, 'div')}
    ` const emptyDocument = serializeCanonicalJson({ type: 'doc', version: 1 }, 'compact') const formRefusals: Readonly> = { - bare: "the first paragraph of an item or cell holding content and no attribute stands bare on its line, as in
  • Bolt
  • ", - 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

    on its line, straight after

  • or ', + 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
  • Bolt
  • ", + 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

    straight after the item's or cell's start tag", } +const headCloser = '\n\n' +const headToBody = '\n\n' // Reads back what `adfToHtml` writes, as it or a browser spells it. export function htmlToAdf(html: string): Result { @@ -280,7 +280,7 @@ function readRow(html: string, index: number, place: Place, table: Open): Result } function firstRowRefusal(html: string, index: number, place: Place | undefined): Result { - return refused('unmappable-html', `a table's first row stands in where every cell is a and it holds one, and in otherwise: ${foundAt(html, index)}`, html, index, place) + return refused('unmappable-html', `a table's first row stands in where it holds at least one cell and all its cells are , and in otherwise: ${foundAt(html, index)}`, html, index, place) } function readCell(html: string, index: number, place: Place, depth: number): Result { diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 029bbb3..84a36bc 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -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')) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 178c120..a6dc213 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -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 '---' } diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 965c02c..15762ea 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -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): 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): 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', () => {