Lead the refusals ordinary editing meets with the edit that fixes them

This commit is contained in:
2026-10-03 15:05:36 +02:00
parent d9f2d325ca
commit 5cffe82dd5
4 changed files with 26 additions and 8 deletions
+1 -1
View File
@@ -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-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 | | `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-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 ## The guarantees
+1 -1
View File
@@ -313,7 +313,7 @@ function partText(items: readonly Inline[], scan: Scan): Result<AdfNode[]> {
const previous = items[index - 1] const previous = items[index - 1]
const next = items[index + 1] const next = items[index + 1]
if (previous === undefined || next === undefined || !isNode(previous) || !isNode(next) || !readsAsOne(previous, next)) { 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) return success(parted)
+17 -3
View File
@@ -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' 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\n')), form)
assert.equal(content(markdownToAdf('!adf:doc x {content=none}\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) 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('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: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') 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', () => { 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' }])
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\\"}"}' 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`]) { 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) 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.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' }]) 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',
)
})
+7 -3
View File
@@ -18,6 +18,7 @@ import { mintTaskIds } from './task-ids.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts' import { parseInlineContent } from './inline-content.ts'
import { readBlockDirectiveNode } from './directive-nodes.ts' import { readBlockDirectiveNode } from './directive-nodes.ts'
import { spellsEmpty } from '../empty-keys.ts'
import { unsupportedNodeShape } from '../directive-syntax.ts' import { unsupportedNodeShape } from '../directive-syntax.ts'
type Paragraph = Extract<Block, { kind: 'paragraph' }> type Paragraph = Extract<Block, { kind: 'paragraph' }>
@@ -58,7 +59,7 @@ function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErr
for (const [index, block] of blocks.entries()) { for (const [index, block] of blocks.entries()) {
const nodePath = [...path, 'content', content.length] const nodePath = [...path, 'content', content.length]
if (block.kind === 'directive' && block.name === documentName) { 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) return positioned(faulted(fault, nodePath), block.position)
} }
if (block.kind === 'directive' && block.name === listBreakName) { 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 { function documentFault(block: DirectiveBlock): ConvertFault | undefined {
const spelled = block.attributes.get(documentAttribute.key) 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 && 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`) 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 const { contentModel, node } = read
if (blocks === undefined) return success(node) if (blocks === undefined) return success(node)
// A directive builds a content key only from content=empty, which holds no body. // 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 === 'code') return codeDirectiveNode(node, blocks, path)
if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path) if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path)
return containerNode(node, blocks, reading, path, depth) return containerNode(node, blocks, reading, path, depth)
@@ -248,7 +252,7 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver
const [first] = fences 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 (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.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 attribute = nodeAttrs(node)['language']
const fromFence = first.language !== '' const fromFence = first.language !== ''
const slot = languageSlot(fromFence ? first.language : attribute) const slot = languageSlot(fromFence ? first.language : attribute)