Merge pull request 'Carry a known node standing where no section spells it' (#18) from carry-misplaced into main
CI / gate (push) Successful in 4s

This commit was merged in pull request #18.
This commit is contained in:
2026-08-26 21:50:20 +02:00
5 changed files with 8 additions and 15 deletions
+3 -2
View File
@@ -28,8 +28,9 @@ Round-trip equality is a property tested over a corpus, not a claim made in pros
- Unknown ADF node: carried opaquely — raw JSON rides a dedicated syntax in both formats and - Unknown ADF node: carried opaquely — raw JSON rides a dedicated syntax in both formats and
restores to a deep-equal node. The round-trip holds for documents newer than the library. So restores to a deep-equal node. The round-trip holds for documents newer than the library. So
does a known node no section spells where it stands, `hardBreak`, `listItem` and `text` where a does a known node no section spells where it stands: a markdown serializer spells a node by type
block belongs excepted. Where a container's own spelling cannot hold the child it has — a without checking its position, and refusing loses a document ADF itself keeps in an
`unsupportedBlock`. Where a container's own spelling cannot hold the child it has — a
`bulletList` outside `listItem`, a `codeBlock` outside text — the error result names that `bulletList` outside `listItem`, a `codeBlock` outside text — the error result names that
instead. instead.
- Unmappable foreign HTML element: error result naming the element — never a silent drop. - Unmappable foreign HTML element: error result naming the element — never a silent drop.
+2 -4
View File
@@ -114,10 +114,8 @@ literal-text fallback — a typo that reparses as prose is the silent loss §2 r
## The opaque carry (AGENTS.md §3) ## The opaque carry (AGENTS.md §3)
A node no section spells where it stands — an unknown type, or a known one whose spelling belongs A node no section spells where it stands — an unknown type, or a known one whose spelling belongs
to the other position — rides as its raw JSON and restores to a deep-equal node. `hardBreak`, to the other position — rides as its raw JSON and restores to a deep-equal node. Block and inline
`listItem` and `text` where a block belongs are the exception: their spelling lives inside another positions canonicalize differently, each fitting where it sits:
node's body, so the misplacement is a named error. Block and inline positions canonicalize
differently, each fitting where it sits:
- **Block position**: a fenced code block with info string `adf`, body = the node's JSON — - **Block position**: a fenced code block with info string `adf`, body = the node's JSON —
two-space indent, object keys sorted. two-space indent, object keys sorted.
+3 -1
View File
@@ -117,6 +117,9 @@ test('carries a node type no section spells', () => {
assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```adf\n{\n "type": "blockCard"\n}\n```\n') assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```adf\n{\n "type": "blockCard"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```adf\n{\n "type": "toString"\n}\n```\n') assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```adf\n{\n "type": "toString"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), ':adf{json="{\\"type\\":\\"blockCard\\"}"}\n') assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), ':adf{json="{\\"type\\":\\"blockCard\\"}"}\n')
assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```adf\n{\n "text": "x",\n "type": "text"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'listItem' }))), '```adf\n{\n "type": "listItem"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```adf\n{\n "type": "hardBreak"\n}\n```\n')
}) })
test('carries the code block whose language is the reserved info string', () => { test('carries the code block whose language is the reserved info string', () => {
@@ -143,7 +146,6 @@ test('refuses a carried node nested deeper than the emitter carries', () => {
}) })
test('refuses a node whose content model the canonical form cannot emit', () => { test('refuses a node whose content model the canonical form cannot emit', () => {
assert.equal(code(adfToMarkdown(document({ type: 'listItem' }))), 'unsupported-node-shape')
assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), 'unsupported-node-shape')
assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), 'unsupported-node-shape')
assert.equal(code(adfToMarkdown(document({ type: 'bulletList' }))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(document({ type: 'bulletList' }))), 'unsupported-node-shape')
-3
View File
@@ -91,9 +91,6 @@ function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result
if (node.type === 'table') return emitTable(node, directive, path, depth) if (node.type === 'table') return emitTable(node, directive, path, depth)
return emitDirectiveBlock(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 commonMarkLine(carriedBlock(node, path)) return commonMarkLine(carriedBlock(node, path))
} }
-5
View File
@@ -46,11 +46,6 @@ detail is settled at its own milestone.
`::paragraph` beside a CommonMark block included — and, since a `mediaSingle`'s spelling now `::paragraph` beside a CommonMark block included — and, since a `mediaSingle`'s spelling now
follows whether CommonMark can spell its URL, two sibling images differing only by an follows whether CommonMark can spell its URL, two sibling images differing only by an
`&` land in the same refusal. `&` land in the same refusal.
**Also blocked**: whether `hardBreak`, `listItem` and `text` where a block belongs carry too,
rather than the shape error §3 records. The prior art splits — a markdown serializer spells a
node by type without checking the position it stands in (`mdast-util-to-markdown`,
`prosemirror-markdown`), while Atlassian's own validator calls it `INVALID_CONTENT` or wraps
it in `unsupportedBlock`/`unsupportedInline` — so §8 leaves the pick here.
- [x] **1d1 — The CommonMark subset**: blockquote, bulletList, codeBlock, heading, orderedList, - [x] **1d1 — The CommonMark subset**: blockquote, bulletList, codeBlock, heading, orderedList,
paragraph, rule, listItem, hardBreak, text, code spans, and the `code`, `em`, `link`, 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. `strike` and `strong` marks — one mark per text node; nesting is 1d3's.