diff --git a/README.md b/README.md index e14b29a..2196a61 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ emit refuses: | `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span | | `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines | | `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | -| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the lossless flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body | +| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the lossless flavour spells as CommonMark | write the shape the message names, or remove the reserved directive it names; `spec/flavour.md` lists every type's attributes and body | ## The guarantees diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index a2d6d24..8743d99 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -313,7 +313,7 @@ function partText(items: readonly Inline[], scan: Scan): Result { const previous = items[index - 1] const next = items[index + 1] if (previous === undefined || next === undefined || !isNode(previous) || !isNode(next) || !readsAsOne(previous, next)) { - return failure('unsupported-node-shape', `${textBreakName} parts two text nodes CommonMark reads back as one: this one parts something else`, scan.path) + return failure('unsupported-node-shape', `delete ${textBreakSpelling} here: it stands only between two runs of text with the same formatting, which would otherwise read as one`, scan.path) } } return success(parted) diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index f692896..2c72bfe 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -92,9 +92,12 @@ test('builds an empty document from input holding no block, and one holding no c const form = 'unsupported-node-shape: doc spells the one form !adf:doc {content=none}: this one spells another' assert.equal(content(markdownToAdf('!adf:doc\n')), form) assert.equal(content(markdownToAdf('!adf:doc x {content=none}\n')), form) - assert.equal(content(markdownToAdf('!adf:doc {content=empty}\n')), form) + assert.equal( + content(markdownToAdf('!adf:doc {content=empty}\n')), + 'unsupported-node-shape: an empty document is empty markdown, and !adf:doc {content=none} spells a document holding no content key: this one spells content=empty', + ) assert.equal(content(markdownToAdf('!adf:doc {content="none"}\n')), form) - const alone = 'unsupported-node-shape: !adf:doc {content=none} spells a whole document holding no content key, alone: this one stands among other blocks' + const alone = 'unsupported-node-shape: delete the !adf:doc {content=none} line to give the document content: it stands only as the whole document' assert.equal(content(markdownToAdf('x\n\n!adf:doc {content=none}\n')), alone) assert.equal(content(markdownToAdf('!adf:panel info\n!adf:doc {content=none}\n!adf:/panel\n')), alone) assert.equal(content(markdownToAdf('!adf:doc\n!adf:/doc\n')), 'malformed-directive: doc takes no body, so no !adf:/doc closes it; \\!adf: keeps the prefix literal') @@ -1087,7 +1090,7 @@ test('names the directive mark left without the content it wraps', () => { test('reads the text break only between two text nodes CommonMark joins, building no node', () => { assert.deepEqual(content(markdownToAdf('a!adf:textBreak{}b\n')), [{ content: [text('a'), text('b')], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('==a!adf:textBreak{}b==\n')), [{ content: [text('==a'), text('b==')], type: 'paragraph' }]) - const parts = 'unsupported-node-shape: textBreak parts two text nodes CommonMark reads back as one: this one parts something else' + const parts = 'unsupported-node-shape: delete !adf:textBreak{} here: it stands only between two runs of text with the same formatting, which would otherwise read as one' const carried = '!adf:carry{json="{\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}' for (const markdown of ['!adf:textBreak{}a\n', 'a!adf:textBreak{}\n', 'a!adf:textBreak{}!adf:textBreak{}b\n', '**a**!adf:textBreak{}b\n', '[!adf:textBreak{}](/u)\n', `a!adf:textBreak{}${carried}\n`]) { assert.equal(content(markdownToAdf(markdown)), parts, markdown) @@ -1097,3 +1100,14 @@ test('reads the text break only between two text nodes CommonMark joins, buildin assert.equal(content(markdownToAdf('a!adf:textBreak{x=y}b\n')), 'unsupported-node-shape: textBreak spells the bare leaf form, !adf:textBreak{}: this one spells more') assert.deepEqual(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"textBreak\\"}"}\n')), [{ content: [{ type: 'textBreak' }], type: 'paragraph' }]) }) + +test('leads a refusal ordinary editing meets with the edit that fixes it', () => { + assert.equal( + content(markdownToAdf('!adf:paragraph {content=empty}\nText.\n!adf:/paragraph\n')), + 'unsupported-node-shape: remove content=empty to give the paragraph a body: content=empty holds none, and this one holds one', + ) + assert.equal( + content(markdownToAdf('!adf:codeBlock\n```\na\n```\n```\n```\n!adf:/codeBlock\n')), + 'unsupported-node-shape: delete the empty fence: a fence beside another holds code, and this one holds none', + ) +}) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 5a6bdc3..9fda1e0 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -18,6 +18,7 @@ import { mintTaskIds } from './task-ids.ts' import { parseBlocks } from './blocks.ts' import { parseInlineContent } from './inline-content.ts' import { readBlockDirectiveNode } from './directive-nodes.ts' +import { spellsEmpty } from '../empty-keys.ts' import { unsupportedNodeShape } from '../directive-syntax.ts' type Paragraph = Extract @@ -58,7 +59,7 @@ function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErr for (const [index, block] of blocks.entries()) { const nodePath = [...path, 'content', content.length] if (block.kind === 'directive' && block.name === documentName) { - const fault = documentFault(block) ?? unsupportedNodeShape(`${documentSpelling} spells a whole document holding no content key, alone: this one stands among other blocks`) + const fault = documentFault(block) ?? unsupportedNodeShape(`delete the ${documentSpelling} line to give the document content: it stands only as the whole document`) return positioned(faulted(fault, nodePath), block.position) } if (block.kind === 'directive' && block.name === listBreakName) { @@ -85,6 +86,9 @@ function listBreakFault(block: DirectiveBlock, previous: Block | undefined, next function documentFault(block: DirectiveBlock): ConvertFault | undefined { const spelled = block.attributes.get(documentAttribute.key) if (block.argument === undefined && block.attributes.size === 1 && spelled?.spelling === documentAttribute.value) return undefined + if (block.argument === undefined && block.attributes.size === 1 && spellsEmpty(spelled)) { + return unsupportedNodeShape(`an empty document is empty markdown, and ${documentSpelling} spells a document holding no content key: this one spells ${documentAttribute.key}=empty`) + } return unsupportedNodeShape(`${documentName} spells the one form ${documentSpelling}: this one spells another`) } @@ -232,7 +236,7 @@ function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, re const { contentModel, node } = read if (blocks === undefined) return success(node) // A directive builds a content key only from content=empty, which holds no body. - if (node.content !== undefined) return blocks.length === 0 ? success(node) : failure('unsupported-node-shape', `${node.type} spells content=empty, which holds no body: this one holds one`, path) + if (node.content !== undefined) return blocks.length === 0 ? success(node) : failure('unsupported-node-shape', `remove content=empty to give the ${node.type} a body: content=empty holds none, and this one holds one`, path) if (contentModel === 'code') return codeDirectiveNode(node, blocks, path) if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path) return containerNode(node, blocks, reading, path, depth) @@ -248,7 +252,7 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver const [first] = fences if (first === undefined) return failure('unsupported-node-shape', `${node.type} takes code blocks as its body: this body holds none`, path) if (fences.some((fence) => fence.language !== first.language)) return failure('unsupported-node-shape', `${node.type} holds one language, so its fences carry one info string: these differ`, path) - if (fences.length > 1 && fences.some((fence) => fence.text === '')) return failure('unsupported-node-shape', `a fence beside another spells a text node, which holds text: this one is empty`, path) + if (fences.length > 1 && fences.some((fence) => fence.text === '')) return failure('unsupported-node-shape', `delete the empty fence: a fence beside another holds code, and this one holds none`, path) const attribute = nodeAttrs(node)['language'] const fromFence = first.language !== '' const slot = languageSlot(fromFence ? first.language : attribute)