Write the same plain markdown for documents the editor holds equal: normalize the reduction's input again, naming a refusal by the caller's document

This commit is contained in:
2026-10-03 14:32:34 +02:00
parent 3da45a9da7
commit 20a191e7e9
4 changed files with 28 additions and 6 deletions
+3 -2
View File
@@ -40,8 +40,9 @@ shape the editor would not.
empty `attrs`, `content` or `marks` and `-0` included, with no normalization on either side. empty `attrs`, `content` or `marks` and `-0` included, with no normalization on either side.
CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader
builds what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction builds what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction
writes editor-normal ADF — adjacent text nodes of identical marks and no attributes merged, `-0` reads and writes editor-normal ADF — adjacent text nodes of identical marks and no attributes
as `0`, and an empty `attrs`, `content` or `marks` the absent key. merged, `-0` as `0`, and an empty `attrs`, `content` or `marks` the absent key — so two documents
the editor holds equal write the same plain markdown.
## `!adf:textBreak{}` parts text CommonMark would join ## `!adf:textBreak{}` parts text CommonMark would join
+10
View File
@@ -8,6 +8,7 @@ import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
import { adfToPlainMarkdown, reduceToPlain } from '../markdown/emit/plain-reduction.ts' import { adfToPlainMarkdown, reduceToPlain } from '../markdown/emit/plain-reduction.ts'
import { directivePrefix } from '../markdown/directive-syntax.ts' import { directivePrefix } from '../markdown/directive-syntax.ts'
import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts' import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
const gateRuns = 1600 const gateRuns = 1600
const renamedPrefix = '!adg:' const renamedPrefix = '!adg:'
@@ -61,3 +62,12 @@ test('a generated document writes plain markdown refusing only what the guard re
propertyRuns(gateRuns), propertyRuns(gateRuns),
) )
}) })
test('a generated document writes the plain markdown its editor-normal form writes', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (document) => {
assert.deepEqual(adfToPlainMarkdown(document), adfToPlainMarkdown(toEditorNormal(document)))
}),
propertyRuns(gateRuns),
)
})
@@ -338,3 +338,9 @@ test('keeps the nodes the plain flavour spells and degrades only what it cannot'
const reduced = reduceToPlain(document(node('panel', { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType: 'info' }, said('x')), tasks)) const reduced = reduceToPlain(document(node('panel', { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType: 'info' }, said('x')), tasks))
assert.deepEqual(reduced.ok ? reduced.value : undefined, document(node('panel', { panelType: 'info' }, said('x')), { content: [node('taskItem', { state: 'DONE' }, text('t'))], type: 'taskList' })) assert.deepEqual(reduced.ok ? reduced.value : undefined, document(node('panel', { panelType: 'info' }, said('x')), { content: [node('taskItem', { state: 'DONE' }, text('t'))], type: 'taskList' }))
}) })
test('writes what the editor-normal form of the document writes', () => {
assert.equal(plain({ attrs: { order: -0 }, content: [{ content: [], type: 'listItem' }], type: 'orderedList' }), '0.\n')
assert.equal(plain({ content: [], text: 'hi', type: 'futureInline' }), 'hi\n')
assert.equal(plain(paragraph({ marks: [{ attrs: {}, type: 'code' }], text: 'a', type: 'text' }, { marks: [{ type: 'code' }], text: '|b', type: 'text' })), '`a|b`\n')
})
+9 -4
View File
@@ -51,9 +51,14 @@ export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
const fault = adfDocumentFault(document) const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, []) if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
// A refusal's path indexes the caller's document, so only the reduction's output normalizes (docs/decisions.md §Equality is deep). // docs/decisions.md §Equality is deep: the reduction reads and writes editor-normal ADF.
const blocks = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] }) const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
return blocks.ok ? success({ content: nodeContent(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 })).slice(), type: 'doc', version: 1 }) : blocks if (!blocks.ok) {
// Merging text renumbers siblings, so the caller's document names the refusal's path.
const raw = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
return raw.ok ? blocks : raw
}
return success({ content: nodeContent(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 })).slice(), type: 'doc', version: 1 })
} }
function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> { function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
@@ -86,7 +91,7 @@ function reduceStanding(node: AdfNode, reduction: Reduction): Result<AdfNode[]>
function standsInline(node: AdfNode): boolean { function standsInline(node: AdfNode): boolean {
if (node.type === 'text' || inlineNodeModel(node.type) !== undefined) return true if (node.type === 'text' || inlineNodeModel(node.type) !== undefined) return true
return !isBlockNodeType(node.type) && node.content === undefined return !isBlockNodeType(node.type) && nodeContent(node).length === 0
} }
function reduceBody(node: AdfNode, reduction: Reduction): Result<AdfNode[]> { function reduceBody(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {