From 56072d36ef1e0c8eba65789f831242ed5a9437d5 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 25 Aug 2026 15:10:02 +0200 Subject: [PATCH] Emitter 2b: the block-node directives, the pipe table and the refusal corpus --- README.md | 3 +- ci.sh | 5 +- corpus/README.md | 3 + corpus/unspellable/block-local-id.error | 1 + corpus/unspellable/block-local-id.json | 18 ++ .../code-block-empty-language.error | 1 + .../code-block-empty-language.json | 18 ++ .../container-block-separation.error | 1 + .../container-block-separation.json | 40 +++++ .../link-destination-parenthesis.error | 1 + .../link-destination-parenthesis.json | 23 +++ corpus/unspellable/link-title-quote.error | 1 + corpus/unspellable/link-title-quote.json | 24 +++ corpus/unspellable/media-empty-alt.error | 1 + corpus/unspellable/media-empty-alt.json | 22 +++ corpus/unspellable/nested-list-tight.error | 1 + corpus/unspellable/nested-list-tight.json | 47 ++++++ .../unspellable/ordered-list-start-one.error | 1 + .../unspellable/ordered-list-start-one.json | 28 ++++ package.json | 2 +- src/adf-to-markdown.test.ts | 94 ++++++++++- src/adf-to-markdown.ts | 157 ++++++++++++++---- src/block-directives.ts | 116 +++++++++++++ src/corpus.test.ts | 29 +++- src/directive-attributes.ts | 35 ++++ src/markdown-escaping.ts | 10 +- src/markdown-inline.ts | 19 ++- src/markdown-tables.ts | 53 ++++++ src/result.ts | 2 + todo.md | 15 +- 30 files changed, 715 insertions(+), 56 deletions(-) create mode 100644 corpus/unspellable/block-local-id.error create mode 100644 corpus/unspellable/block-local-id.json create mode 100644 corpus/unspellable/code-block-empty-language.error create mode 100644 corpus/unspellable/code-block-empty-language.json create mode 100644 corpus/unspellable/container-block-separation.error create mode 100644 corpus/unspellable/container-block-separation.json create mode 100644 corpus/unspellable/link-destination-parenthesis.error create mode 100644 corpus/unspellable/link-destination-parenthesis.json create mode 100644 corpus/unspellable/link-title-quote.error create mode 100644 corpus/unspellable/link-title-quote.json create mode 100644 corpus/unspellable/media-empty-alt.error create mode 100644 corpus/unspellable/media-empty-alt.json create mode 100644 corpus/unspellable/nested-list-tight.error create mode 100644 corpus/unspellable/nested-list-tight.json create mode 100644 corpus/unspellable/ordered-list-start-one.error create mode 100644 corpus/unspellable/ordered-list-start-one.json create mode 100644 src/block-directives.ts create mode 100644 src/directive-attributes.ts create mode 100644 src/markdown-tables.ts diff --git a/README.md b/README.md index 062cbb0..30b8317 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and an HTML dialect. -**Status: pre-release — `adfToMarkdown` emits the CommonMark subset, nothing else is built.** +**Status: pre-release — `adfToMarkdown` emits the CommonMark subset and the block nodes, nothing +else is built.** Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar: [`spec/flavour.md`](spec/flavour.md). diff --git a/ci.sh b/ci.sh index 3d17b21..ceb3dbb 100755 --- a/ci.sh +++ b/ci.sh @@ -8,7 +8,10 @@ in_node() { docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/app" - in_node npm ci in_node npm run typecheck -test_output=$(in_node npm test) +if ! test_output=$(in_node npm test 2>&1); then + printf '%s\n' "$test_output" + exit 1 +fi printf '%s\n' "$test_output" if printf '%s' "$test_output" | grep -q 'ℹ tests 0'; then echo 'the gate ran zero tests — failing instead of a vacuous green' diff --git a/corpus/README.md b/corpus/README.md index 2137c61..6df1efe 100644 --- a/corpus/README.md +++ b/corpus/README.md @@ -9,6 +9,9 @@ One directory per contract kind: `markdownToAdf` must build from it. One-way; the markdown is not canonical. - `errors/` — `.md`: markdown input that must not convert. A `.error` beside it pins which error. +- `unspellable/` — `.json`: ADF `adfToMarkdown` must refuse, the `ConvertErrorCode` in the + `.error` beside it. A maintainer decision (`todo.md`) moves a document from here to + `round-trip/`. - `real-payloads/` — `.json`: sanitized live ADF, round-tripped ADF→markdown→ADF. No expected markdown. diff --git a/corpus/unspellable/block-local-id.error b/corpus/unspellable/block-local-id.error new file mode 100644 index 0000000..b4a73d2 --- /dev/null +++ b/corpus/unspellable/block-local-id.error @@ -0,0 +1 @@ +unspelled-node-attribute diff --git a/corpus/unspellable/block-local-id.json b/corpus/unspellable/block-local-id.json new file mode 100644 index 0000000..681618c --- /dev/null +++ b/corpus/unspellable/block-local-id.json @@ -0,0 +1,18 @@ +{ + "content": [ + { + "attrs": { + "localId": "01a03d5c-9b21-73f4-8e6a-0c47b1d9e2f8" + }, + "content": [ + { + "text": "Every block node in the schema carries one.", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/unspellable/code-block-empty-language.error b/corpus/unspellable/code-block-empty-language.error new file mode 100644 index 0000000..a53e1bc --- /dev/null +++ b/corpus/unspellable/code-block-empty-language.error @@ -0,0 +1 @@ +ambiguous-empty-code-block-language diff --git a/corpus/unspellable/code-block-empty-language.json b/corpus/unspellable/code-block-empty-language.json new file mode 100644 index 0000000..d0b5da7 --- /dev/null +++ b/corpus/unspellable/code-block-empty-language.json @@ -0,0 +1,18 @@ +{ + "content": [ + { + "attrs": { + "language": "" + }, + "content": [ + { + "text": "cargo build --release", + "type": "text" + } + ], + "type": "codeBlock" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/unspellable/container-block-separation.error b/corpus/unspellable/container-block-separation.error new file mode 100644 index 0000000..7fdd935 --- /dev/null +++ b/corpus/unspellable/container-block-separation.error @@ -0,0 +1 @@ +unspelled-block-separation diff --git a/corpus/unspellable/container-block-separation.json b/corpus/unspellable/container-block-separation.json new file mode 100644 index 0000000..93d1a55 --- /dev/null +++ b/corpus/unspellable/container-block-separation.json @@ -0,0 +1,40 @@ +{ + "content": [ + { + "attrs": { + "title": "Full build log" + }, + "content": [ + { + "content": [ + { + "text": "The build ran for 11 minutes.", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "attrs": { + "panelType": "warning" + }, + "content": [ + { + "content": [ + { + "text": "Three warnings went unread.", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "panel" + } + ], + "type": "expand" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/unspellable/link-destination-parenthesis.error b/corpus/unspellable/link-destination-parenthesis.error new file mode 100644 index 0000000..8da7bcc --- /dev/null +++ b/corpus/unspellable/link-destination-parenthesis.error @@ -0,0 +1 @@ +unspellable-link-destination diff --git a/corpus/unspellable/link-destination-parenthesis.json b/corpus/unspellable/link-destination-parenthesis.json new file mode 100644 index 0000000..ce1a2f4 --- /dev/null +++ b/corpus/unspellable/link-destination-parenthesis.json @@ -0,0 +1,23 @@ +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "attrs": { + "href": "https://example.com/a)b" + }, + "type": "link" + } + ], + "text": "The torque table", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/unspellable/link-title-quote.error b/corpus/unspellable/link-title-quote.error new file mode 100644 index 0000000..a1f2950 --- /dev/null +++ b/corpus/unspellable/link-title-quote.error @@ -0,0 +1 @@ +unspellable-link-title diff --git a/corpus/unspellable/link-title-quote.json b/corpus/unspellable/link-title-quote.json new file mode 100644 index 0000000..0db4010 --- /dev/null +++ b/corpus/unspellable/link-title-quote.json @@ -0,0 +1,24 @@ +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "attrs": { + "href": "https://example.com/torque", + "title": "He said \"hi\"" + }, + "type": "link" + } + ], + "text": "The torque table", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/unspellable/media-empty-alt.error b/corpus/unspellable/media-empty-alt.error new file mode 100644 index 0000000..a7d35ec --- /dev/null +++ b/corpus/unspellable/media-empty-alt.error @@ -0,0 +1 @@ +ambiguous-empty-media-alt diff --git a/corpus/unspellable/media-empty-alt.json b/corpus/unspellable/media-empty-alt.json new file mode 100644 index 0000000..eaa9b0b --- /dev/null +++ b/corpus/unspellable/media-empty-alt.json @@ -0,0 +1,22 @@ +{ + "content": [ + { + "attrs": { + "layout": "center" + }, + "content": [ + { + "attrs": { + "alt": "", + "type": "external", + "url": "https://example.com/moon.png" + }, + "type": "media" + } + ], + "type": "mediaSingle" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/unspellable/nested-list-tight.error b/corpus/unspellable/nested-list-tight.error new file mode 100644 index 0000000..2dc039a --- /dev/null +++ b/corpus/unspellable/nested-list-tight.error @@ -0,0 +1 @@ +unspellable-line-start diff --git a/corpus/unspellable/nested-list-tight.json b/corpus/unspellable/nested-list-tight.json new file mode 100644 index 0000000..b3752d9 --- /dev/null +++ b/corpus/unspellable/nested-list-tight.json @@ -0,0 +1,47 @@ +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "text": "Torque the bolts", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "attrs": { + "order": 2 + }, + "content": [ + { + "content": [ + { + "content": [ + { + "text": "Then sign off", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "listItem" + } + ], + "type": "orderedList" + } + ], + "type": "listItem" + } + ], + "type": "bulletList" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/unspellable/ordered-list-start-one.error b/corpus/unspellable/ordered-list-start-one.error new file mode 100644 index 0000000..e7b3b24 --- /dev/null +++ b/corpus/unspellable/ordered-list-start-one.error @@ -0,0 +1 @@ +ambiguous-ordered-list-start diff --git a/corpus/unspellable/ordered-list-start-one.json b/corpus/unspellable/ordered-list-start-one.json new file mode 100644 index 0000000..93d7709 --- /dev/null +++ b/corpus/unspellable/ordered-list-start-one.json @@ -0,0 +1,28 @@ +{ + "content": [ + { + "attrs": { + "order": 1 + }, + "content": [ + { + "content": [ + { + "content": [ + { + "text": "Loosen the clamp", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "listItem" + } + ], + "type": "orderedList" + } + ], + "type": "doc", + "version": 1 +} diff --git a/package.json b/package.json index 93bc33d..89ec78f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "node": ">=24" }, "scripts": { - "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=93 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", + "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=94 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/src/adf-to-markdown.test.ts b/src/adf-to-markdown.test.ts index 9126351..28f3f71 100644 --- a/src/adf-to-markdown.test.ts +++ b/src/adf-to-markdown.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' -import type { AdfDocument, AdfMark, AdfNode } from './adf-document.ts' +import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf-document.ts' import type { Result } from './result.ts' import { adfToMarkdown } from './index.ts' @@ -116,7 +116,8 @@ test('refuses two adjacent lists of the same kind', () => { }) test('refuses a node type the canonical form does not cover', () => { - assert.equal(code(adfToMarkdown(document({ type: 'panel' }))), 'unsupported-node-type') + assert.equal(code(adfToMarkdown(document({ type: 'blockCard' }))), 'unsupported-node-type') + assert.equal(code(adfToMarkdown(document({ type: 'toString' }))), 'unsupported-node-type') assert.equal(code(adfToMarkdown(document(paragraph({ type: 'mention' })))), 'unsupported-node-type') }) @@ -283,3 +284,92 @@ test('refuses a document nested deeper than the emitter carries', () => { test('emits an empty list item without trailing whitespace', () => { assert.equal(markdown(adfToMarkdown(document({ content: [{ type: 'listItem' }], type: 'bulletList' }))), '-\n') }) + +test('spells a block directive as its node type, arg and attributes', () => { + const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' }) + assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), ':::panel warning\nx\n:::\n') + assert.equal(markdown(adfToMarkdown(panel({}))), ':::panel\nx\n:::\n') + assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), ':::caption\n:::\n') + assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '::syncBlock {localId=a}\n') +}) + +test('refuses a directive attribute no section spells', () => { + assert.equal(code(adfToMarkdown(document({ attrs: { rounded: true }, type: 'panel' }))), 'unspelled-node-attribute') + assert.equal(code(adfToMarkdown(document({ attrs: { toString: 'x' }, type: 'panel' }))), 'unspelled-node-attribute') + assert.equal(code(adfToMarkdown(document({ attrs: { localId: 4 }, type: 'panel' }))), 'unsupported-node-shape') + assert.equal(code(adfToMarkdown(document({ attrs: { width: '50' }, type: 'layoutColumn' }))), 'unsupported-node-shape') + assert.equal(code(adfToMarkdown(document({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }))), 'unsupported-node-shape') +}) + +test('refuses an arg slot value no bare token spells', () => { + assert.equal(code(adfToMarkdown(document({ attrs: { panelType: 'extra info' }, type: 'panel' }))), 'unspelled-node-attribute') + assert.equal(code(adfToMarkdown(document({ attrs: { state: 2 }, type: 'taskItem' }))), 'unspelled-node-attribute') +}) + +test('carries a block node mark in the reserved attribute', () => { + const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' }) + assert.equal(markdown(adfToMarkdown(section({ type: 'breakout' }))), ':::layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n:::\n') + assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), ':::layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n:::\n') +}) + +test('refuses the content a directive body has no room for', () => { + assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'media' }))), 'unsupported-node-shape') + assert.equal(code(adfToMarkdown(document({ text: 'x', type: 'panel' }))), 'unsupported-node-shape') +}) + +test('separates two directive blocks in a container body by one line, two CommonMark blocks by a blank one', () => { + const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' }) + const panel = (...content: AdfNode[]): AdfDocument => document({ attrs: { panelType: 'info' }, content, type: 'panel' }) + assert.equal(markdown(adfToMarkdown(panel(text('a'), text('b')))), ':::panel info\na\n\nb\n:::\n') + assert.equal(markdown(adfToMarkdown(panel({ type: 'caption' }, { type: 'caption' }))), '::::panel info\n:::caption\n:::\n:::caption\n:::\n::::\n') + assert.equal(code(adfToMarkdown(panel(text('a'), { type: 'caption' }))), 'unspelled-block-separation') + assert.equal(code(adfToMarkdown(panel({ type: 'caption' }, text('a')))), 'unspelled-block-separation') + assert.equal(code(adfToMarkdown(panel(paragraph(), text('a')))), 'unspelled-block-separation') +}) + +test('spells the image form for exactly the centered external media shape', () => { + const url = 'https://example.com/moon.png' + const single = (attrs: AdfAttributes, ...content: AdfNode[]): AdfDocument => + document({ attrs: { layout: 'center' }, content: [{ attrs, content, type: 'media' }], type: 'mediaSingle' }) + assert.equal(markdown(adfToMarkdown(single({ alt: 'The moon', type: 'external', url }))), `![The moon](${url})\n`) + assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `![](${url})\n`) + assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${url})\n`) + assert.equal(code(adfToMarkdown(single({ alt: '', type: 'external', url }))), 'ambiguous-empty-media-alt') + assert.equal(code(adfToMarkdown(single({ alt: 'a\nb', type: 'external', url }))), 'unspellable-whitespace') + assert.equal(code(adfToMarkdown(single({ alt: 'a\u0000b', type: 'external', url }))), 'unspellable-character') + assert.equal(code(adfToMarkdown(single({ type: 'external', url: 'https://example.com/a b>c' }))), 'unspellable-link-destination') + assert.equal(code(adfToMarkdown(single({ alt: 4, type: 'external', url }))), 'unsupported-node-shape') + assert.equal(code(adfToMarkdown(single({ type: 'external', url: 4 }))), 'unsupported-node-shape') + assert.equal(code(adfToMarkdown(single({ type: 'external', url }, paragraph()))), 'unsupported-node-shape') +}) + +test('spells a mediaSingle the image form does not fit as a directive', () => { + const media: AdfNode = { attrs: { type: 'external', url: 'https://example.com/moon.png' }, type: 'media' } + const single: AdfNode = { attrs: { layout: 'center' }, content: [media], marks: [{ type: 'border' }], type: 'mediaSingle' } + assert.equal( + markdown(adfToMarkdown(document(single))), + ':::mediaSingle {layout=center marks="[{\\"type\\":\\"border\\"}]"}\n::media {type=external url="https://example.com/moon.png"}\n:::\n', + ) +}) + +test('spells a table as a pipe table only where every row and cell is plain', () => { + const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' }) + const cell = (type: string, ...content: AdfNode[]): AdfNode => ({ content, type }) + const row = (...cells: AdfNode[]): AdfNode => ({ content: cells, type: 'tableRow' }) + const table = (...rows: AdfNode[]): AdfDocument => document({ content: rows, type: 'table' }) + const directive = (result: Result): boolean => markdown(result).startsWith(':') + const header = row(cell('tableHeader', text('Part'))) + assert.equal(markdown(adfToMarkdown(table(header, row(cell('tableCell', text('Bolt M8')))))), '| Part |\n| --- |\n| Bolt M8 |\n') + assert.equal(markdown(adfToMarkdown(table(row(cell('tableHeader', text('a|b')))))), '| a\\|b |\n| --- |\n') + assert.ok(directive(adfToMarkdown(table()))) + assert.ok(directive(adfToMarkdown(table(row())))) + assert.ok(directive(adfToMarkdown(table({ type: 'tableRow' })))) + assert.ok(directive(adfToMarkdown(table(header, row())))) + assert.ok(directive(adfToMarkdown(table(row(cell('tableCell', text('Bolt M8'))))))) + assert.ok(directive(adfToMarkdown(table(cell('tableHeader', text('Part')))))) + assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader')))))) + assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', text('a'), text('b'))))))) + assert.equal(code(adfToMarkdown(table(row(cell('tableHeader', { attrs: { localId: 'a' }, type: 'paragraph' }))))), 'unspelled-node-attribute') + assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', { attrs: { level: 1 }, type: 'heading' })))))) + assert.equal(code(adfToMarkdown(table(row(cell('tableHeader', { content: [{ text: ' a', type: 'text' }], type: 'paragraph' }))))), 'unspellable-whitespace') +}) diff --git a/src/adf-to-markdown.ts b/src/adf-to-markdown.ts index 6378c79..9554e31 100644 --- a/src/adf-to-markdown.ts +++ b/src/adf-to-markdown.ts @@ -1,46 +1,75 @@ import type { AdfDocument, AdfNode } from './adf-document.ts' +import type { BlockDirective } from './block-directives.ts' import type { JsonValue } from './json-value.ts' -import { emitInlineLine } from './markdown-inline.ts' +import { blockDirective, spellDirectiveHeader } from './block-directives.ts' +import { emitImageLine, emitInlineLine } from './markdown-inline.ts' +import { emitPipeTable } from './markdown-tables.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts' import { isAdfDocument } from './adf-document.ts' import { largestNesting } from './nesting.ts' import { longestBacktickRun } from './backtick-runs.ts' +import { serializeCanonicalJson } from './canonical-json.ts' +type BlockContainer = 'directive' | 'document' | 'list-item' +type EmittedBody = { fenceColons: number; text: string } +type EmittedBlock = EmittedBody & { node: AdfNode; path: ConvertErrorPath } + +const centeredMediaSingle = '{"layout":"center"}' +const imageAttributes = ['alt', 'type', 'url'] const largestListMarker = 999999999 const listTypes = ['bulletList', 'orderedList'] export function adfToMarkdown(document: AdfDocument): Result { if (!isAdfDocument(document)) return failure('not-an-adf-document', 'the value is not an ADF document', []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) - const blocks = emitBlocks(document.content ?? [], false, [], 0) + const blocks = emitBlocks(document.content ?? [], 'document', [], 0) if (!blocks.ok) return blocks - return success(blocks.value === '' ? '' : `${blocks.value}\n`) + return success(blocks.value.text === '' ? '' : `${blocks.value.text}\n`) } -function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean, path: ConvertErrorPath, depth: number): Result { +function emitBlocks(nodes: readonly AdfNode[], container: BlockContainer, path: ConvertErrorPath, depth: number): Result { if (depth > largestNesting) return failure('unsupported-node-shape', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path) - let output = '' - let previous: AdfNode | undefined + const blocks: EmittedBlock[] = [] for (const [index, node] of nodes.entries()) { const nodePath = [...path, 'content', index] - if (previous !== undefined) { - if (listTypes.includes(node.type) && previous.type === node.type) { - return failure('unspellable-adjacent-lists', `two adjacent ${node.type} nodes read back as one list`, nodePath) - } - if (inListItem && listTypes.includes(node.type)) { - if (!interruptsParagraph(node)) { - return failure('unspellable-line-start', `a ${node.type} that cannot interrupt the block above it has no tight spelling`, nodePath) - } - output += '\n' - } else output += '\n\n' - } const block = emitBlock(node, nodePath, depth) if (!block.ok) return block - output += block.value - previous = node + blocks.push({ ...block.value, node, path: nodePath }) } - return success(output) + let fenceColons = 0 + let text = '' + for (const [index, block] of blocks.entries()) { + const previous = blocks[index - 1] + if (previous !== undefined) { + const separation = separationBetween(previous, block, container) + if (!separation.ok) return separation + text += separation.value + } + fenceColons = Math.max(fenceColons, block.fenceColons) + text += block.text + } + return success({ fenceColons, text }) +} + +function separationBetween(previous: EmittedBlock, next: EmittedBlock, container: BlockContainer): Result { + if (listTypes.includes(next.node.type) && previous.node.type === next.node.type) { + return failure('unspellable-adjacent-lists', `two adjacent ${next.node.type} nodes read back as one list`, next.path) + } + if (container === 'list-item' && listTypes.includes(next.node.type)) { + if (!interruptsParagraph(next.node)) { + return failure('unspellable-line-start', `a ${next.node.type} that cannot interrupt the block above it has no tight spelling`, next.path) + } + return success('\n') + } + if (container !== 'directive') return success('\n\n') + if (previous.fenceColons === 0 && next.fenceColons === 0) return success('\n\n') + if (previous.fenceColons > 0 && next.fenceColons > 0) return success('\n') + return failure( + 'unspelled-block-separation', + `the canonical form leaves the separation between a ${previous.node.type} and a ${next.node.type} in a container body unspelled`, + next.path, + ) } function interruptsParagraph(node: AdfNode): boolean { @@ -48,26 +77,86 @@ function interruptsParagraph(node: AdfNode): boolean { return ((node.content ?? [])[0]?.content ?? []).length > 0 } -function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result { - if (node.type === 'blockquote') return emitBlockquote(node, path, depth) - if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth) - if (node.type === 'codeBlock') return emitCodeBlock(node, path) - if (node.type === 'heading') return emitHeading(node, path) +function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result { + if (node.type === 'blockquote') return plainBlock(emitBlockquote(node, path, depth)) + if (node.type === 'bulletList' || node.type === 'orderedList') return plainBlock(emitList(node, path, depth)) + if (node.type === 'codeBlock') return plainBlock(emitCodeBlock(node, path)) + if (node.type === 'heading') return plainBlock(emitHeading(node, path)) if (node.type === 'paragraph') return emitParagraph(node, path) - if (node.type === 'rule') return emitRule(node, path) + if (node.type === 'rule') return plainBlock(emitRule(node, path)) + const directive = blockDirective(node.type) + if (directive !== undefined) { + if (node.type === 'mediaSingle') return emitMediaSingle(node, directive, path, depth) + if (node.type === 'table') return emitTable(node, directive, path, depth) + return emitDirectiveBlock(node, directive, path, depth) + } if (node.type === 'hardBreak' || node.type === 'listItem' || node.type === 'text') { return failure('unsupported-node-shape', `a ${node.type} node cannot stand where a block belongs`, path) } return failure('unsupported-node-type', `the canonical form spells no block node of type ${node.type}`, path) } +function plainBlock(text: Result): Result { + if (!text.ok) return text + return success({ fenceColons: 0, text: text.value }) +} + +function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result { + if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path) + const header = spellDirectiveHeader(node, directive, path) + if (!header.ok) return header + const content = node.content ?? [] + if (directive.body === 'none') { + if (content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path) + return success({ fenceColons: 2, text: `::${header.value}` }) + } + const body = directive.body === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1) + if (!body.ok) return body + const fenceColons = Math.max(3, body.value.fenceColons + 1) + const fence = ':'.repeat(fenceColons) + const lines = body.value.text === '' ? '' : `${body.value.text}\n` + return success({ fenceColons, text: `${fence}${header.value}\n${lines}${fence}` }) +} + +function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Result { + if (content.length === 0) return success({ fenceColons: 0, text: '' }) + return plainBlock(emitInlineLine(content, 'paragraph', path)) +} + +function emitMediaSingle(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result { + const image = imageShape(node) + if (image === undefined) return emitDirectiveBlock(node, directive, path, depth) + const mediaPath = [...path, 'content', 0] + if (image.alt === '') return failure('ambiguous-empty-media-alt', 'an empty media alt and an absent one share one image spelling', mediaPath) + return plainBlock(emitImageLine(image.alt, image.url, mediaPath)) +} + +function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined { + const content = node.content ?? [] + const media = content[0] + if (serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle || (node.marks ?? []).length > 0) return undefined + if (media === undefined || content.length !== 1 || media.type !== 'media' || (media.marks ?? []).length > 0 || (media.content ?? []).length > 0) return undefined + const attrs = media.attrs ?? {} + const alt = attrs['alt'] + const url = attrs['url'] + if (Object.keys(attrs).some((key) => !imageAttributes.includes(key)) || attrs['type'] !== 'external') return undefined + if (typeof url !== 'string' || (alt !== undefined && typeof alt !== 'string')) return undefined + return { alt, url } +} + +function emitTable(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result { + const pipe = emitPipeTable(node, path) + if (pipe === undefined) return emitDirectiveBlock(node, directive, path, depth) + return plainBlock(pipe) +} + function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result { const validation = validateBlockNode(node, [], path) if (!validation.ok) return validation - const inner = emitBlocks(node.content ?? [], false, path, depth + 1) + const inner = emitBlocks(node.content ?? [], 'document', path, depth + 1) if (!inner.ok) return inner return success( - inner.value + inner.value.text .split('\n') .map((line) => (line === '' ? '>' : `> ${line}`)) .join('\n'), @@ -152,11 +241,11 @@ function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result< function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result { const validation = validateBlockNode(item, [], path) if (!validation.ok) return validation - const inner = emitBlocks(item.content ?? [], true, path, depth + 1) + const inner = emitBlocks(item.content ?? [], 'list-item', path, depth + 1) if (!inner.ok) return inner - if (inner.value === '') return success(marker.trimEnd()) + if (inner.value.text === '') return success(marker.trimEnd()) const indent = ' '.repeat(marker.length) - const lines = inner.value.split('\n').map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`)) + const lines = inner.value.text.split('\n').map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`)) const first = lines[0] ?? '' if (isThematicBreak(first)) { return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(first)}`, path) @@ -164,12 +253,12 @@ function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, dep return success(lines.join('\n')) } -function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result { +function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result { const validation = validateBlockNode(node, [], path) if (!validation.ok) return validation const content = node.content ?? [] - if (content.length === 0) return success('::paragraph') - return emitInlineLine(content, 'paragraph', path) + if (content.length === 0) return success({ fenceColons: 2, text: '::paragraph' }) + return plainBlock(emitInlineLine(content, 'paragraph', path)) } function emitRule(node: AdfNode, path: ConvertErrorPath): Result { diff --git a/src/block-directives.ts b/src/block-directives.ts new file mode 100644 index 0000000..5c88841 --- /dev/null +++ b/src/block-directives.ts @@ -0,0 +1,116 @@ +import type { AdfMark, AdfNode } from './adf-document.ts' +import type { AttributeKind } from './directive-attributes.ts' +import type { JsonValue } from './json-value.ts' +import { failure, success, type ConvertErrorPath, type Result } from './result.ts' +import { isBareToken, spellAttributeValue, spellAttributes, spellJsonAttribute } from './directive-attributes.ts' + +export type BlockDirective = { + argument?: string + attributes: Readonly> + body: 'block' | 'inline' | 'none' +} + +const cellAttributes: Readonly> = { + background: 'string', + colspan: 'number', + colwidth: 'json', + localId: 'string', + rowspan: 'number', + valign: 'string', +} + +const expandAttributes: Readonly> = { localId: 'string', title: 'string' } + +const extensionAttributes: Readonly> = { + extensionKey: 'string', + extensionType: 'string', + layout: 'string', + localId: 'string', + parameters: 'json', + text: 'string', +} + +const itemAttributes: Readonly> = { localId: 'string' } + +const mediaAttributes: Readonly> = { + alt: 'string', + collection: 'string', + height: 'number', + id: 'string', + localId: 'string', + occurrenceKey: 'string', + type: 'string', + url: 'string', + width: 'number', +} + +const syncBlockAttributes: Readonly> = { localId: 'string', resourceId: 'string' } + +const blockDirectives: Readonly> = { + blockTaskItem: { argument: 'state', attributes: itemAttributes, body: 'block' }, + bodiedExtension: { attributes: extensionAttributes, body: 'block' }, + bodiedSyncBlock: { attributes: syncBlockAttributes, body: 'block' }, + caption: { attributes: itemAttributes, body: 'inline' }, + decisionItem: { attributes: { localId: 'string', state: 'string' }, body: 'inline' }, + decisionList: { attributes: itemAttributes, body: 'block' }, + expand: { attributes: expandAttributes, body: 'block' }, + extension: { attributes: extensionAttributes, body: 'none' }, + extensionFrame: { attributes: {}, body: 'block' }, + layoutColumn: { attributes: { localId: 'string', valign: 'string', width: 'number' }, body: 'block' }, + layoutSection: { attributes: itemAttributes, body: 'block' }, + media: { attributes: mediaAttributes, body: 'none' }, + mediaGroup: { attributes: {}, body: 'block' }, + mediaSingle: { attributes: { layout: 'string', localId: 'string', width: 'number', widthType: 'string' }, body: 'block' }, + multiBodiedExtension: { attributes: extensionAttributes, body: 'block' }, + nestedExpand: { attributes: expandAttributes, body: 'block' }, + panel: { + argument: 'panelType', + attributes: { localId: 'string', panelColor: 'string', panelIcon: 'string', panelIconId: 'string', panelIconText: 'string' }, + body: 'block', + }, + syncBlock: { attributes: syncBlockAttributes, body: 'none' }, + table: { attributes: { displayMode: 'string', isNumberColumnEnabled: 'boolean', layout: 'string', localId: 'string', width: 'number' }, body: 'block' }, + tableCell: { attributes: cellAttributes, body: 'block' }, + tableHeader: { attributes: cellAttributes, body: 'block' }, + tableRow: { attributes: itemAttributes, body: 'block' }, + taskItem: { argument: 'state', attributes: itemAttributes, body: 'inline' }, + taskList: { attributes: itemAttributes, body: 'block' }, +} + +export function blockDirective(type: string): BlockDirective | undefined { + return Object.hasOwn(blockDirectives, type) ? blockDirectives[type] : undefined +} + +export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result { + const pairs: [string, string][] = [] + let argument = '' + for (const [key, value] of Object.entries(node.attrs ?? {})) { + if (key === directive.argument) { + if (typeof value !== 'string' || !isBareToken(value)) { + return failure('unspelled-node-attribute', `the ${node.type} attribute ${key} holds no bare token the arg slot spells`, path) + } + argument = ` ${value}` + continue + } + const kind = attributeKind(directive, key) + if (kind === undefined) return failure('unspelled-node-attribute', `the ${node.type} attribute ${key} has no canonical markdown spelling`, path) + const spelled = spellAttributeValue(value, kind) + if (spelled === undefined) return failure('unsupported-node-shape', `the ${node.type} attribute ${key} holds no ${kind}`, path) + pairs.push([key, spelled]) + } + const marks = node.marks ?? [] + if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))]) + const attributes = spellAttributes(pairs) + return success(`${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}`) +} + +function attributeKind(directive: BlockDirective, key: string): AttributeKind | undefined { + return Object.hasOwn(directive.attributes, key) ? directive.attributes[key] : undefined +} + +function markValues(marks: readonly AdfMark[]): JsonValue { + return marks.map((mark) => { + const attrs = mark.attrs ?? {} + return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type } + }) +} diff --git a/src/corpus.test.ts b/src/corpus.test.ts index 18345e6..79118fd 100644 --- a/src/corpus.test.ts +++ b/src/corpus.test.ts @@ -11,9 +11,10 @@ import { serializeCanonicalJson } from './canonical-json.ts' const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus') const roundTripRoot = join(corpusRoot, 'round-trip') +const unspellableRoot = join(corpusRoot, 'unspellable') -const emittingDirectories = ['commonmark-subset'] -const pendingDirectories = ['block-nodes', 'inline-nodes'] +const emittingDirectories = ['block-nodes', 'commonmark-subset'] +const pendingDirectories = ['inline-nodes'] function directoryNames(root: string): string[] { return readdirSync(root, { withFileTypes: true }) @@ -23,7 +24,11 @@ function directoryNames(root: string): string[] { } function fixtureNames(directory: string, extension: string): string[] { - return readdirSync(join(roundTripRoot, directory)) + return names(join(roundTripRoot, directory), extension) +} + +function names(root: string, extension: string): string[] { + return readdirSync(root) .filter((name) => name.endsWith(extension)) .map((name) => name.slice(0, -extension.length)) .sort() @@ -67,6 +72,24 @@ for (const directory of emittingDirectories) { } } +test('unspellable pairs every .json with an .error', () => { + assert.deepEqual(names(unspellableRoot, '.json'), names(unspellableRoot, '.error')) +}) + +test('unspellable holds fixtures', () => { + assert.ok(names(unspellableRoot, '.json').length > 0) +}) + +for (const name of names(unspellableRoot, '.json')) { + test(`unspellable/${name} is refused with the error it names`, () => { + const parsed: unknown = JSON.parse(readFileSync(join(unspellableRoot, `${name}.json`), 'utf8')) + assert.ok(isAdfDocument(parsed), `${name}.json is not an ADF document`) + const result = adfToMarkdown(parsed) + assert.ok(!result.ok, result.ok ? `emitted ${JSON.stringify(result.value)}` : '') + assert.equal(result.error.code, readFileSync(join(unspellableRoot, `${name}.error`), 'utf8').trimEnd()) + }) +} + test('the corpus holds JSON to gate', () => { assert.ok(corpusJsonPaths().length > 0) }) diff --git a/src/directive-attributes.ts b/src/directive-attributes.ts new file mode 100644 index 0000000..76f697b --- /dev/null +++ b/src/directive-attributes.ts @@ -0,0 +1,35 @@ +import type { JsonValue } from './json-value.ts' +import { serializeCanonicalJson } from './canonical-json.ts' + +export type AttributeKind = 'boolean' | 'json' | 'number' | 'string' + +const bareToken = /^[A-Za-z0-9_-]+$/ + +export function isBareToken(text: string): boolean { + return bareToken.test(text) +} + +export function spellAttributes(pairs: readonly (readonly [string, string])[]): string { + if (pairs.length === 0) return '' + const spelled = [...pairs].sort(([left], [right]) => (left < right ? -1 : 1)).map(([key, value]) => `${key}=${value}`) + return `{${spelled.join(' ')}}` +} + +export function spellAttributeValue(value: JsonValue, kind: AttributeKind): string | undefined { + if (kind === 'json') return spellJsonAttribute(value) + if (kind === 'boolean') return typeof value === 'boolean' ? `${value}` : undefined + if (kind === 'number') return typeof value === 'number' ? spell(JSON.stringify(value)) : undefined + return typeof value === 'string' ? spell(value) : undefined +} + +export function spellJsonAttribute(value: JsonValue): string { + return quote(serializeCanonicalJson(value, 'compact')) +} + +function spell(text: string): string { + return isBareToken(text) ? text : quote(text) +} + +function quote(text: string): string { + return JSON.stringify(text) +} diff --git a/src/markdown-escaping.ts b/src/markdown-escaping.ts index 833528d..d2a8d3c 100644 --- a/src/markdown-escaping.ts +++ b/src/markdown-escaping.ts @@ -6,7 +6,7 @@ export type InlineSegment = export type AssembledLine = { line: string; unspellableMark: string | undefined } -export type LineContainer = 'heading' | 'paragraph' +export type LineContainer = 'heading' | 'paragraph' | 'table-cell' type DelimiterRun = { character: string; closeMark: string | undefined; end: number; openMark: string | undefined; start: number } @@ -127,8 +127,9 @@ function isSyntax(kind: InlineSegment['kind'] | undefined): boolean { } function opensConstruct(scan: string, index: number, inLinkText: boolean, container: LineContainer, escaped: ReadonlySet): boolean { - const claimsLine = container === 'heading' ? closesHeading(scan, index) : claimsLineStart(scan, index) - return claimsLine || claimsCharacter(scan, index, inLinkText, escaped) + if (container === 'heading' && closesHeading(scan, index)) return true + if (container === 'paragraph' && claimsLineStart(scan, index)) return true + return claimsCharacter(scan, index, inLinkText, container, escaped) } function claimsLineStart(scan: string, index: number): boolean { @@ -144,10 +145,11 @@ function closesHeading(scan: string, index: number): boolean { return index === 0 || /[ \t]/.test(scan.charAt(index - 1)) } -function claimsCharacter(scan: string, index: number, inLinkText: boolean, escaped: ReadonlySet): boolean { +function claimsCharacter(scan: string, index: number, inLinkText: boolean, container: LineContainer, escaped: ReadonlySet): boolean { const character = scan.charAt(index) const rest = scan.slice(index) if (inLinkText && (character === '[' || character === ']')) return true + if (character === '|') return container === 'table-cell' if (character === '\\') return asciiPunctuation.test(scan.charAt(index + 1)) if (character === '&') return startsEntityReference(rest) if (character === '<') return opensBracketedAutolink(rest) || htmlConstructs.some((construct) => construct.test(rest)) diff --git a/src/markdown-inline.ts b/src/markdown-inline.ts index 66c8c58..54f0332 100644 --- a/src/markdown-inline.ts +++ b/src/markdown-inline.ts @@ -20,7 +20,20 @@ const linkAttributes = ['href', 'title'] export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, container, inLinkText: false, path }) if (!segments.ok) return segments - const assembled = assembleInlineLine(segments.value, container) + return finishLine(segments.value, container, path) +} + +export function emitImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): Result { + if (alt !== undefined && /[\n\r]/.test(alt)) return failure('unspellable-whitespace', 'a media alt holds a newline no image description spells', path) + if (alt !== undefined && holdsNullCharacter(alt)) return failure('unspellable-character', 'a media alt holds a null character CommonMark replaces', path) + const destination = spellDestination(href, path) + if (!destination.ok) return destination + const description: InlineSegment[] = alt === undefined ? [] : [{ kind: 'link-text', text: alt }] + return finishLine([{ kind: 'syntax', text: '![' }, ...description, { kind: 'syntax', text: `](${destination.value})` }], 'paragraph', path) +} + +function finishLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result { + const assembled = assembleInlineLine(segments, container) if (assembled.unspellableMark !== undefined) { return failure('unspellable-mark', `the ${assembled.unspellableMark} spelling cannot open or close where it sits`, path) } @@ -83,8 +96,8 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result< const types = (node.marks ?? []).map((mark) => mark.type) if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path) if (node.type === 'hardBreak') { - if (context.container === 'heading' || context.atBlockEnd) return success([{ kind: 'syntax', text: ':hardBreak{}' }]) - return success([{ kind: 'syntax', text: '\\\n' }]) + if (context.container === 'paragraph' && !context.atBlockEnd) return success([{ kind: 'syntax', text: '\\\n' }]) + return success([{ kind: 'syntax', text: ':hardBreak{}' }]) } if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node carries no text', path) if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node carries content', path) diff --git a/src/markdown-tables.ts b/src/markdown-tables.ts new file mode 100644 index 0000000..e4d6dc3 --- /dev/null +++ b/src/markdown-tables.ts @@ -0,0 +1,53 @@ +import type { AdfNode } from './adf-document.ts' +import { emitInlineLine } from './markdown-inline.ts' +import { success, type ConvertErrorPath, type Result } from './result.ts' + +export function emitPipeTable(node: AdfNode, path: ConvertErrorPath): Result | undefined { + const rows = pipeRows(node) + if (rows === undefined) return undefined + const lines: string[] = [] + for (const [rowIndex, row] of rows.entries()) { + const cells: string[] = [] + for (const [cellIndex, paragraph] of row.entries()) { + const content = paragraph.content ?? [] + const line = content.length === 0 ? success('') : emitInlineLine(content, 'table-cell', [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0]) + if (!line.ok) return line + cells.push(line.value) + } + lines.push(`| ${cells.join(' | ')} |`) + if (rowIndex === 0) lines.push(`| ${cells.map(() => '---').join(' | ')} |`) + } + return success(lines.join('\n')) +} + +function pipeRows(node: AdfNode): AdfNode[][] | undefined { + const rows = node.content ?? [] + const columns = (rows[0]?.content ?? []).length + if (!isPlain(node) || columns === 0) return undefined + const grid: AdfNode[][] = [] + for (const [index, row] of rows.entries()) { + const cells = row.content ?? [] + if (row.type !== 'tableRow' || !isPlain(row) || cells.length !== columns) return undefined + const wanted = index === 0 ? 'tableHeader' : 'tableCell' + const paragraphs: AdfNode[] = [] + for (const cell of cells) { + const paragraph = plainParagraph(cell) + if (paragraph === undefined || cell.type !== wanted || !isPlain(cell)) return undefined + paragraphs.push(paragraph) + } + grid.push(paragraphs) + } + return grid +} + +function isPlain(node: AdfNode): boolean { + return Object.keys(node.attrs ?? {}).length === 0 && (node.marks ?? []).length === 0 && node.text === undefined +} + +function plainParagraph(cell: AdfNode): AdfNode | undefined { + const content = cell.content ?? [] + const paragraph = content[0] + if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !isPlain(paragraph)) return undefined + const pipedCode = (paragraph.content ?? []).some((child) => (child.marks ?? []).some((mark) => mark.type === 'code') && (child.text ?? '').includes('|')) + return pipedCode ? undefined : paragraph +} diff --git a/src/result.ts b/src/result.ts index 89969bc..f2edebd 100644 --- a/src/result.ts +++ b/src/result.ts @@ -1,5 +1,6 @@ export type ConvertErrorCode = | 'ambiguous-empty-code-block-language' + | 'ambiguous-empty-media-alt' | 'ambiguous-ordered-list-start' | 'not-an-adf-document' | 'reserved-adf-language' @@ -13,6 +14,7 @@ export type ConvertErrorCode = | 'unspellable-mark' | 'unspellable-whitespace' | 'unspelled-block-marks' + | 'unspelled-block-separation' | 'unspelled-node-attribute' | 'unsupported-document-version' | 'unsupported-heading-level' diff --git a/todo.md b/todo.md index 447e9c8..bf79310 100644 --- a/todo.md +++ b/todo.md @@ -28,12 +28,12 @@ detail is settled at its own milestone. carries a `localId` with no spelling, `codeBlock` also `hideLineNumbers`, `uniqueId` and `wrap`, `blockquote` also marks, and `hardBreak` `text` and `localId` with no section for the carry fallback to reach. Picking one (directive sections for those nodes, or the opaque - carry) is a permanent format decision (§8). Three collision sites are held out of the corpus - meanwhile, each a choice between the absent attribute and the empty value: a `codeBlock` - whose info string is empty and `media` with an empty `alt`, which one "exactly that shape" - rule — as the CommonMark image already uses — could settle together, and an `orderedList` - starting at 1, independent of the totality answer since `order: 9` keeps the markdown form - either way. **Also blocked**: the link rule covers destination spaces only, so two shapes + carry) is a permanent format decision (§8). Three collision sites wait in + `corpus/unspellable/` meanwhile, each a choice between the absent attribute and the empty + value: a `codeBlock` whose info string is empty and `media` with an empty `alt`, which one + "exactly that shape" rule — as the CommonMark image already uses — could settle together, + and an `orderedList` starting at 1, independent of the totality answer since `order: 9` + keeps the markdown form either way. **Also blocked**: the link rule covers destination spaces only, so two shapes break §2 silently — href `https://example.com/a)b` emits `[t](https://example.com/a)b)`, read back as href `…/a` plus literal `b)`; title `He said "hi"` emits `[t](u "He said "hi"")`, which holds no title. Two defensible spellings each — angle @@ -42,7 +42,8 @@ detail is settled at its own milestone. directive block in a container body — an `expand` whose content is `paragraph` "A" then a `panel` (`panelType` `warning`) holding "B" spells `A` and `:::panel warning` either on consecutive lines or with a blank line between. Two defensible spellings, so §8 leaves the - pick here. + pick here; `unspelled-block-separation` refuses the pair meanwhile, an empty paragraph's + `::paragraph` beside a CommonMark block included. - [x] **1d1 — The CommonMark subset**: blockquote, bulletList, codeBlock, heading, orderedList, paragraph, rule, listItem, hardBreak, text, code spans, and the `code`, `em`, `link`, `strike` and `strong` marks — one mark per text node; nesting is 1d3's.