diff --git a/docs/decisions.md b/docs/decisions.md index 60c20f9..f93d396 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -40,8 +40,9 @@ shape the editor would not. 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 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` -as `0`, and an empty `attrs`, `content` or `marks` the absent key. +reads and writes editor-normal ADF — adjacent text nodes of identical marks and no attributes +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 diff --git a/src/conformance/adf-property.test.ts b/src/conformance/adf-property.test.ts index 0061ea9..4b787ac 100644 --- a/src/conformance/adf-property.test.ts +++ b/src/conformance/adf-property.test.ts @@ -8,6 +8,7 @@ import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts' import { adfToPlainMarkdown, reduceToPlain } from '../markdown/emit/plain-reduction.ts' import { directivePrefix } from '../markdown/directive-syntax.ts' import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts' +import { toEditorNormal } from '../adf/editor-normal.ts' const gateRuns = 1600 const renamedPrefix = '!adg:' @@ -61,3 +62,12 @@ test('a generated document writes plain markdown refusing only what the guard re 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), + ) +}) diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts index 2a3e15a..185ae42 100644 --- a/src/markdown/emit/plain-reduction.test.ts +++ b/src/markdown/emit/plain-reduction.test.ts @@ -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)) 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') +}) diff --git a/src/markdown/emit/plain-reduction.ts b/src/markdown/emit/plain-reduction.ts index fb8916b..055e7e1 100644 --- a/src/markdown/emit/plain-reduction.ts +++ b/src/markdown/emit/plain-reduction.ts @@ -51,9 +51,14 @@ export function reduceToPlain(document: AdfDocument): Result { const fault = adfDocumentFault(document) if (fault !== undefined) return faulted(fault, []) 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). - const blocks = reduceBlocks(nodeContent(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 + // docs/decisions.md §Equality is deep: the reduction reads and writes editor-normal ADF. + const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] }) + 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 { @@ -86,7 +91,7 @@ function reduceStanding(node: AdfNode, reduction: Reduction): Result function standsInline(node: AdfNode): boolean { 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 {