From 9ab7286e6d5f48b678f0261ba3a68b0025d5f2dd Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sat, 3 Oct 2026 12:25:01 +0200 Subject: [PATCH] Make markdownToAdf(adfToMarkdown(doc)) deep-equal doc: textBreak, empty keys, -0, typed carry fences, code blocks of several text nodes --- browser-tests/convert-corpus.js | 9 +- browser-tests/run.js | 7 +- .../errors/carry-fence-type-spellable.error | 1 + corpus/errors/carry-fence-type-spellable.md | 5 + corpus/errors/carry-fence-type.error | 1 + corpus/errors/carry-fence-type.md | 5 + corpus/errors/carry-invalid-json.md | 4 +- .../errors/code-block-fence-languages.error | 1 + corpus/errors/code-block-fence-languages.md | 8 + corpus/errors/document-directive-bare.error | 1 + corpus/errors/document-directive-bare.md | 1 + .../errors/document-directive-misplaced.error | 1 + corpus/errors/document-directive-misplaced.md | 3 + .../errors/empty-attrs-with-attributes.error | 1 + corpus/errors/empty-attrs-with-attributes.md | 3 + corpus/errors/empty-content-with-body.error | 1 + corpus/errors/empty-content-with-body.md | 3 + corpus/errors/empty-key-value.error | 1 + corpus/errors/empty-key-value.md | 3 + corpus/errors/empty-marks-wrapped.error | 1 + corpus/errors/empty-marks-wrapped.md | 1 + corpus/errors/text-break-attributes.error | 1 + corpus/errors/text-break-attributes.md | 1 + corpus/errors/text-break-content.error | 1 + corpus/errors/text-break-content.md | 1 + corpus/errors/text-break-marks-differ.error | 1 + corpus/errors/text-break-marks-differ.md | 1 + corpus/errors/text-break-misplaced.error | 1 + corpus/errors/text-break-misplaced.md | 1 + .../block-nodes/code-block-carry.json | 14 +- .../block-nodes/code-block-carry.md | 12 +- .../block-nodes/code-block-text-nodes.json | 56 +++++++ .../block-nodes/code-block-text-nodes.md | 31 ++++ .../block-nodes/document-without-content.json | 4 + .../block-nodes/document-without-content.md | 1 + corpus/round-trip/block-nodes/empty-keys.json | 89 ++++++++++ corpus/round-trip/block-nodes/empty-keys.md | 32 ++++ .../combinations/carry-attributes.md | 10 +- .../combinations/negative-zero.json | 83 ++++++++++ .../round-trip/combinations/negative-zero.md | 21 +++ .../commonmark-subset/document-empty.json | 1 + .../round-trip/inline-nodes/empty-keys.json | 98 +++++++++++ corpus/round-trip/inline-nodes/empty-keys.md | 9 + .../round-trip/inline-nodes/text-break.json | 154 ++++++++++++++++++ corpus/round-trip/inline-nodes/text-break.md | 13 ++ .../opaque-carry/carry-in-wrappers.md | 10 +- .../opaque-carry/code-block-children.json | 35 ++++ .../opaque-carry/code-block-children.md | 32 ++++ .../round-trip/opaque-carry/unknown-block.md | 10 +- .../opaque-carry/unknown-type-spelling.json | 21 +++ .../opaque-carry/unknown-type-spelling.md | 24 +++ src/adf/document.ts | 28 +++- src/adf/editor-normal.ts | 4 +- src/canonical-json.ts | 2 +- src/conformance/adf-property.test.ts | 3 +- src/conformance/corpus.test.ts | 9 +- src/conformance/markdown-property.test.ts | 3 +- src/conformance/property-harness.ts | 69 +++++--- src/markdown/block-directive.ts | 14 +- src/markdown/code-language.ts | 8 +- src/markdown/commonmark/grammar.ts | 7 +- src/markdown/directive-syntax.ts | 2 +- src/markdown/emit/adf-to-markdown.test.ts | 85 +++++----- src/markdown/emit/adf-to-markdown.ts | 54 +++--- src/markdown/emit/block-directive-spelling.ts | 3 +- .../emit/inline-directive-spelling.ts | 3 +- src/markdown/emit/inline-line.ts | 36 ++-- src/markdown/emit/plain-reduction.test.ts | 2 +- src/markdown/emit/plain-reduction.ts | 9 +- src/markdown/empty-keys.ts | 30 ++++ src/markdown/mark-spellings.ts | 10 +- src/markdown/opaque-carry.ts | 43 +++-- src/markdown/parse/directive-marks.ts | 8 +- src/markdown/parse/directive-nodes.ts | 41 +++-- src/markdown/parse/inline-content.ts | 86 +++++++--- src/markdown/parse/markdown-to-adf.test.ts | 57 +++++-- src/markdown/parse/markdown-to-adf.ts | 48 ++++-- src/markdown/text-break.ts | 16 ++ 78 files changed, 1263 insertions(+), 246 deletions(-) create mode 100644 corpus/errors/carry-fence-type-spellable.error create mode 100644 corpus/errors/carry-fence-type-spellable.md create mode 100644 corpus/errors/carry-fence-type.error create mode 100644 corpus/errors/carry-fence-type.md create mode 100644 corpus/errors/code-block-fence-languages.error create mode 100644 corpus/errors/code-block-fence-languages.md create mode 100644 corpus/errors/document-directive-bare.error create mode 100644 corpus/errors/document-directive-bare.md create mode 100644 corpus/errors/document-directive-misplaced.error create mode 100644 corpus/errors/document-directive-misplaced.md create mode 100644 corpus/errors/empty-attrs-with-attributes.error create mode 100644 corpus/errors/empty-attrs-with-attributes.md create mode 100644 corpus/errors/empty-content-with-body.error create mode 100644 corpus/errors/empty-content-with-body.md create mode 100644 corpus/errors/empty-key-value.error create mode 100644 corpus/errors/empty-key-value.md create mode 100644 corpus/errors/empty-marks-wrapped.error create mode 100644 corpus/errors/empty-marks-wrapped.md create mode 100644 corpus/errors/text-break-attributes.error create mode 100644 corpus/errors/text-break-attributes.md create mode 100644 corpus/errors/text-break-content.error create mode 100644 corpus/errors/text-break-content.md create mode 100644 corpus/errors/text-break-marks-differ.error create mode 100644 corpus/errors/text-break-marks-differ.md create mode 100644 corpus/errors/text-break-misplaced.error create mode 100644 corpus/errors/text-break-misplaced.md create mode 100644 corpus/round-trip/block-nodes/code-block-text-nodes.json create mode 100644 corpus/round-trip/block-nodes/code-block-text-nodes.md create mode 100644 corpus/round-trip/block-nodes/document-without-content.json create mode 100644 corpus/round-trip/block-nodes/document-without-content.md create mode 100644 corpus/round-trip/block-nodes/empty-keys.json create mode 100644 corpus/round-trip/block-nodes/empty-keys.md create mode 100644 corpus/round-trip/combinations/negative-zero.json create mode 100644 corpus/round-trip/combinations/negative-zero.md create mode 100644 corpus/round-trip/inline-nodes/empty-keys.json create mode 100644 corpus/round-trip/inline-nodes/empty-keys.md create mode 100644 corpus/round-trip/inline-nodes/text-break.json create mode 100644 corpus/round-trip/inline-nodes/text-break.md create mode 100644 corpus/round-trip/opaque-carry/code-block-children.json create mode 100644 corpus/round-trip/opaque-carry/code-block-children.md create mode 100644 corpus/round-trip/opaque-carry/unknown-type-spelling.json create mode 100644 corpus/round-trip/opaque-carry/unknown-type-spelling.md create mode 100644 src/markdown/empty-keys.ts create mode 100644 src/markdown/text-break.ts diff --git a/browser-tests/convert-corpus.js b/browser-tests/convert-corpus.js index 28a6808..3b535bc 100644 --- a/browser-tests/convert-corpus.js +++ b/browser-tests/convert-corpus.js @@ -1,16 +1,19 @@ try { const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js') + const { serializeCanonicalJson } = await import('/dist/canonical-json.js') + // WebDriver's JSON reads -0 back as 0, so a document crosses as its canonical spelling. + const spelled = (result) => (result.ok ? { ok: true, value: `${serializeCanonicalJson(result.value, 'two-space')}\n` } : result) window.convertCorpus = (corpus) => ({ errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })), - normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })), + normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: spelled(markdownToAdf(markdown)) })), realPayloads: corpus.realPayloads.map(({ json, name }) => { const adf = JSON.parse(json) const emitted = adfToMarkdown(adf) - return { emitted, isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? markdownToAdf(emitted.value) : undefined } + return { emitted, isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? spelled(markdownToAdf(emitted.value)) : undefined } }), roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => { const adf = JSON.parse(json) - return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: markdownToAdf(markdown) } + return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: spelled(markdownToAdf(markdown)) } }), }) } catch (cause) { diff --git a/browser-tests/run.js b/browser-tests/run.js index 0369402..6739064 100644 --- a/browser-tests/run.js +++ b/browser-tests/run.js @@ -2,7 +2,6 @@ import assert from 'node:assert/strict' import { createServer } from 'node:http' import { extname, join } from 'node:path' import { readFileSync, readdirSync } from 'node:fs' -import { toEditorNormal } from '../dist/adf/editor-normal.js' const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' } const driver = 'http://127.0.0.1:4444' @@ -107,7 +106,7 @@ for (const [index, { json, markdown, name }] of corpus.roundTrip.entries()) { assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`) assert.equal(result.emitted.value, markdown) assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`) - assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(json)) + assert.equal(result.parsed.value, json) }) } @@ -115,7 +114,7 @@ for (const [index, { name }] of corpus.normalization.entries()) { const result = results.normalization[index] checking(name, result, () => { assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`) - assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(fixture(name, '.json'))) + assert.equal(result.parsed.value, fixture(name, '.json')) }) } @@ -125,7 +124,7 @@ for (const [index, { json, name }] of corpus.realPayloads.entries()) { assert.ok(result.isDocument, `${name}.json is no ADF document`) assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`) assert.ok(result.parsed.ok, `it did not parse back — ${refusal(result.parsed)}`) - assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(json)) + assert.equal(result.parsed.value, json) }) } diff --git a/corpus/errors/carry-fence-type-spellable.error b/corpus/errors/carry-fence-type-spellable.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/carry-fence-type-spellable.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/carry-fence-type-spellable.md b/corpus/errors/carry-fence-type-spellable.md new file mode 100644 index 0000000..1b73fc1 --- /dev/null +++ b/corpus/errors/carry-fence-type-spellable.md @@ -0,0 +1,5 @@ +```adf: +{ + "type": "blockCard" +} +``` diff --git a/corpus/errors/carry-fence-type.error b/corpus/errors/carry-fence-type.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/carry-fence-type.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/carry-fence-type.md b/corpus/errors/carry-fence-type.md new file mode 100644 index 0000000..7a266ef --- /dev/null +++ b/corpus/errors/carry-fence-type.md @@ -0,0 +1,5 @@ +```adf:blockCard +{ + "type": "blockCard" +} +``` diff --git a/corpus/errors/carry-invalid-json.md b/corpus/errors/carry-invalid-json.md index 1cd3435..febfe45 100644 --- a/corpus/errors/carry-invalid-json.md +++ b/corpus/errors/carry-invalid-json.md @@ -1,3 +1,3 @@ -```carry -{"type": +```adf:blockCard +{"attrs": ``` diff --git a/corpus/errors/code-block-fence-languages.error b/corpus/errors/code-block-fence-languages.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/code-block-fence-languages.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/code-block-fence-languages.md b/corpus/errors/code-block-fence-languages.md new file mode 100644 index 0000000..077c903 --- /dev/null +++ b/corpus/errors/code-block-fence-languages.md @@ -0,0 +1,8 @@ +!adf:codeBlock +```js +a +``` +```ts +b +``` +!adf:/codeBlock diff --git a/corpus/errors/document-directive-bare.error b/corpus/errors/document-directive-bare.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/document-directive-bare.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/document-directive-bare.md b/corpus/errors/document-directive-bare.md new file mode 100644 index 0000000..82cf095 --- /dev/null +++ b/corpus/errors/document-directive-bare.md @@ -0,0 +1 @@ +!adf:doc diff --git a/corpus/errors/document-directive-misplaced.error b/corpus/errors/document-directive-misplaced.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/document-directive-misplaced.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/document-directive-misplaced.md b/corpus/errors/document-directive-misplaced.md new file mode 100644 index 0000000..f3158b4 --- /dev/null +++ b/corpus/errors/document-directive-misplaced.md @@ -0,0 +1,3 @@ +!adf:doc {content=none} + +Text. diff --git a/corpus/errors/empty-attrs-with-attributes.error b/corpus/errors/empty-attrs-with-attributes.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/empty-attrs-with-attributes.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/empty-attrs-with-attributes.md b/corpus/errors/empty-attrs-with-attributes.md new file mode 100644 index 0000000..560ecc7 --- /dev/null +++ b/corpus/errors/empty-attrs-with-attributes.md @@ -0,0 +1,3 @@ +!adf:panel info {attrs=empty} +Text. +!adf:/panel diff --git a/corpus/errors/empty-content-with-body.error b/corpus/errors/empty-content-with-body.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/empty-content-with-body.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/empty-content-with-body.md b/corpus/errors/empty-content-with-body.md new file mode 100644 index 0000000..648b150 --- /dev/null +++ b/corpus/errors/empty-content-with-body.md @@ -0,0 +1,3 @@ +!adf:paragraph {content=empty} +Text. +!adf:/paragraph diff --git a/corpus/errors/empty-key-value.error b/corpus/errors/empty-key-value.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/empty-key-value.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/empty-key-value.md b/corpus/errors/empty-key-value.md new file mode 100644 index 0000000..eadca54 --- /dev/null +++ b/corpus/errors/empty-key-value.md @@ -0,0 +1,3 @@ +!adf:paragraph {attrs=none} +Text. +!adf:/paragraph diff --git a/corpus/errors/empty-marks-wrapped.error b/corpus/errors/empty-marks-wrapped.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/empty-marks-wrapped.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/empty-marks-wrapped.md b/corpus/errors/empty-marks-wrapped.md new file mode 100644 index 0000000..8824f00 --- /dev/null +++ b/corpus/errors/empty-marks-wrapped.md @@ -0,0 +1 @@ +**!adf:date{marks=empty}** diff --git a/corpus/errors/text-break-attributes.error b/corpus/errors/text-break-attributes.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/text-break-attributes.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/text-break-attributes.md b/corpus/errors/text-break-attributes.md new file mode 100644 index 0000000..d4f7006 --- /dev/null +++ b/corpus/errors/text-break-attributes.md @@ -0,0 +1 @@ +a!adf:textBreak{x=y}b diff --git a/corpus/errors/text-break-content.error b/corpus/errors/text-break-content.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/text-break-content.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/text-break-content.md b/corpus/errors/text-break-content.md new file mode 100644 index 0000000..c5b44ef --- /dev/null +++ b/corpus/errors/text-break-content.md @@ -0,0 +1 @@ +a!adf:textBreak[x]b diff --git a/corpus/errors/text-break-marks-differ.error b/corpus/errors/text-break-marks-differ.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/text-break-marks-differ.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/text-break-marks-differ.md b/corpus/errors/text-break-marks-differ.md new file mode 100644 index 0000000..4a8f869 --- /dev/null +++ b/corpus/errors/text-break-marks-differ.md @@ -0,0 +1 @@ +**a**!adf:textBreak{}b diff --git a/corpus/errors/text-break-misplaced.error b/corpus/errors/text-break-misplaced.error new file mode 100644 index 0000000..a811539 --- /dev/null +++ b/corpus/errors/text-break-misplaced.error @@ -0,0 +1 @@ +unsupported-node-shape diff --git a/corpus/errors/text-break-misplaced.md b/corpus/errors/text-break-misplaced.md new file mode 100644 index 0000000..e84475f --- /dev/null +++ b/corpus/errors/text-break-misplaced.md @@ -0,0 +1 @@ +!adf:textBreak{}Hello diff --git a/corpus/round-trip/block-nodes/code-block-carry.json b/corpus/round-trip/block-nodes/code-block-carry.json index a5bbd7d..9981d4e 100644 --- a/corpus/round-trip/block-nodes/code-block-carry.json +++ b/corpus/round-trip/block-nodes/code-block-carry.json @@ -14,7 +14,19 @@ }, { "attrs": { - "language": "carry" + "language": "adf:blockCard" + }, + "content": [ + { + "text": "{\n \"attrs\": {}\n}", + "type": "text" + } + ], + "type": "codeBlock" + }, + { + "attrs": { + "language": "adf:" }, "content": [ { diff --git a/corpus/round-trip/block-nodes/code-block-carry.md b/corpus/round-trip/block-nodes/code-block-carry.md index 5a032f7..665bd3c 100644 --- a/corpus/round-trip/block-nodes/code-block-carry.md +++ b/corpus/round-trip/block-nodes/code-block-carry.md @@ -1,12 +1,18 @@ -!adf:codeBlock {language=carry} -``` +```carry { "type": "blockCard" } ``` + +!adf:codeBlock {language="adf:blockCard"} +``` +{ + "attrs": {} +} +``` !adf:/codeBlock -!adf:codeBlock {language=carry} +!adf:codeBlock {language="adf:"} ```` ``` ```` diff --git a/corpus/round-trip/block-nodes/code-block-text-nodes.json b/corpus/round-trip/block-nodes/code-block-text-nodes.json new file mode 100644 index 0000000..37554b6 --- /dev/null +++ b/corpus/round-trip/block-nodes/code-block-text-nodes.json @@ -0,0 +1,56 @@ +{ + "content": [ + { + "attrs": { + "language": "js" + }, + "content": [ + { + "text": "const a = 1\n", + "type": "text" + }, + { + "text": "const b = 2", + "type": "text" + } + ], + "type": "codeBlock" + }, + { + "content": [ + { + "text": "a", + "type": "text" + }, + { + "text": "```", + "type": "text" + }, + { + "text": "\n", + "type": "text" + } + ], + "type": "codeBlock" + }, + { + "attrs": { + "language": "has`tick", + "wrap": true + }, + "content": [ + { + "text": "x", + "type": "text" + }, + { + "text": " y ", + "type": "text" + } + ], + "type": "codeBlock" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/block-nodes/code-block-text-nodes.md b/corpus/round-trip/block-nodes/code-block-text-nodes.md new file mode 100644 index 0000000..cb87dbf --- /dev/null +++ b/corpus/round-trip/block-nodes/code-block-text-nodes.md @@ -0,0 +1,31 @@ +!adf:codeBlock +```js +const a = 1 + +``` +```js +const b = 2 +``` +!adf:/codeBlock + +!adf:codeBlock +``` +a +``` +```` +``` +```` +``` + + +``` +!adf:/codeBlock + +!adf:codeBlock {language="has\u0060tick" wrap=true} +``` +x +``` +``` + y +``` +!adf:/codeBlock diff --git a/corpus/round-trip/block-nodes/document-without-content.json b/corpus/round-trip/block-nodes/document-without-content.json new file mode 100644 index 0000000..6591ef5 --- /dev/null +++ b/corpus/round-trip/block-nodes/document-without-content.json @@ -0,0 +1,4 @@ +{ + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/block-nodes/document-without-content.md b/corpus/round-trip/block-nodes/document-without-content.md new file mode 100644 index 0000000..e23047f --- /dev/null +++ b/corpus/round-trip/block-nodes/document-without-content.md @@ -0,0 +1 @@ +!adf:doc {content=none} diff --git a/corpus/round-trip/block-nodes/empty-keys.json b/corpus/round-trip/block-nodes/empty-keys.json new file mode 100644 index 0000000..3fcc6d2 --- /dev/null +++ b/corpus/round-trip/block-nodes/empty-keys.json @@ -0,0 +1,89 @@ +{ + "content": [ + { + "content": [], + "type": "paragraph" + }, + { + "attrs": {}, + "content": [ + { + "text": "Plain words.", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [], + "type": "rule" + }, + { + "attrs": { + "level": 2 + }, + "content": [ + { + "text": "Title", + "type": "text" + } + ], + "marks": [], + "type": "heading" + }, + { + "attrs": { + "panelType": "info" + }, + "content": [], + "type": "panel" + }, + { + "content": [ + { + "attrs": {}, + "content": [ + { + "content": [ + { + "text": "Item", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "listItem" + }, + { + "content": [ + { + "content": [ + { + "text": "Next", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "listItem" + } + ], + "type": "bulletList" + }, + { + "content": [], + "type": "codeBlock" + }, + { + "attrs": { + "language": "js" + }, + "marks": [], + "type": "codeBlock" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/block-nodes/empty-keys.md b/corpus/round-trip/block-nodes/empty-keys.md new file mode 100644 index 0000000..04299da --- /dev/null +++ b/corpus/round-trip/block-nodes/empty-keys.md @@ -0,0 +1,32 @@ +!adf:paragraph {content=empty} +!adf:/paragraph + +!adf:paragraph {attrs=empty} +Plain words. +!adf:/paragraph + +!adf:rule {content=empty} + +!adf:heading {level=2 marks=empty} +Title +!adf:/heading + +!adf:panel info {content=empty} +!adf:/panel + +!adf:bulletList +!adf:listItem {attrs=empty} +Item +!adf:/listItem +!adf:listItem +Next +!adf:/listItem +!adf:/bulletList + +!adf:codeBlock {content=empty} +!adf:/codeBlock + +!adf:codeBlock {marks=empty} +```js +``` +!adf:/codeBlock diff --git a/corpus/round-trip/combinations/carry-attributes.md b/corpus/round-trip/combinations/carry-attributes.md index 088dbe9..605ffbd 100644 --- a/corpus/round-trip/combinations/carry-attributes.md +++ b/corpus/round-trip/combinations/carry-attributes.md @@ -1,4 +1,4 @@ -```carry +```adf:panel { "attrs": { "rounded": true @@ -13,12 +13,11 @@ ], "type": "paragraph" } - ], - "type": "panel" + ] } ``` -```carry +```adf:panel { "attrs": { "panelType": "extra info" @@ -33,8 +32,7 @@ ], "type": "paragraph" } - ], - "type": "panel" + ] } ``` diff --git a/corpus/round-trip/combinations/negative-zero.json b/corpus/round-trip/combinations/negative-zero.json new file mode 100644 index 0000000..2ffed45 --- /dev/null +++ b/corpus/round-trip/combinations/negative-zero.json @@ -0,0 +1,83 @@ +{ + "content": [ + { + "attrs": { + "order": -0 + }, + "content": [ + { + "content": [ + { + "content": [ + { + "text": "First", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "listItem" + } + ], + "type": "orderedList" + }, + { + "attrs": { + "level": -0 + }, + "content": [ + { + "text": "Title", + "type": "text" + } + ], + "type": "heading" + }, + { + "attrs": { + "extensionKey": "toc", + "extensionType": "com.atlassian.confluence.macro.core", + "parameters": { + "maxLevel": -0 + } + }, + "type": "extension" + }, + { + "content": [ + { + "attrs": { + "data": [ + -0 + ], + "url": "https://example.com/" + }, + "type": "inlineCard" + }, + { + "marks": [ + { + "attrs": { + "color": "#000000", + "size": -0 + }, + "type": "border" + } + ], + "text": "x", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "attrs": { + "x": -0 + }, + "type": "blockCard" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/negative-zero.md b/corpus/round-trip/combinations/negative-zero.md new file mode 100644 index 0000000..766f709 --- /dev/null +++ b/corpus/round-trip/combinations/negative-zero.md @@ -0,0 +1,21 @@ +!adf:orderedList {order=-0} +!adf:listItem +First +!adf:/listItem +!adf:/orderedList + +!adf:heading {level=-0} +Title +!adf:/heading + +!adf:extension {extensionKey=toc extensionType="com.atlassian.confluence.macro.core" parameters="{\"maxLevel\":-0}"} + +!adf:inlineCard{data="[-0]" url="https://example.com/"}!adf:border[x]{color="#000000" size=-0} + +```adf:blockCard +{ + "attrs": { + "x": -0 + } +} +``` diff --git a/corpus/round-trip/commonmark-subset/document-empty.json b/corpus/round-trip/commonmark-subset/document-empty.json index 6591ef5..317b19d 100644 --- a/corpus/round-trip/commonmark-subset/document-empty.json +++ b/corpus/round-trip/commonmark-subset/document-empty.json @@ -1,4 +1,5 @@ { + "content": [], "type": "doc", "version": 1 } diff --git a/corpus/round-trip/inline-nodes/empty-keys.json b/corpus/round-trip/inline-nodes/empty-keys.json new file mode 100644 index 0000000..a5b1623 --- /dev/null +++ b/corpus/round-trip/inline-nodes/empty-keys.json @@ -0,0 +1,98 @@ +{ + "content": [ + { + "content": [ + { + "attrs": {}, + "type": "date" + }, + { + "text": " ", + "type": "text" + }, + { + "content": [], + "type": "date" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "text": "a", + "type": "text" + }, + { + "marks": [], + "type": "hardBreak" + }, + { + "text": "b", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "attrs": { + "id": "x", + "text": "@M" + }, + "content": [], + "type": "mention" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "text": "bare", + "type": "text" + }, + { + "marks": [], + "text": "marked", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "attrs": {}, + "type": "em" + } + ], + "text": "x", + "type": "text" + }, + { + "text": " and ", + "type": "text" + }, + { + "attrs": { + "shortName": ":a:" + }, + "marks": [ + { + "attrs": {}, + "type": "strong" + } + ], + "type": "emoji" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/inline-nodes/empty-keys.md b/corpus/round-trip/inline-nodes/empty-keys.md new file mode 100644 index 0000000..db86fe8 --- /dev/null +++ b/corpus/round-trip/inline-nodes/empty-keys.md @@ -0,0 +1,9 @@ +!adf:date{attrs=empty} !adf:date{content=empty} + +a!adf:hardBreak{marks=empty}b + +!adf:mention[@M]{content=empty id=x} + +bare!adf:carry{json="{\"marks\":[],\"text\":\"marked\",\"type\":\"text\"}"} + +!adf:carry{json="{\"marks\":[{\"attrs\":{},\"type\":\"em\"}],\"text\":\"x\",\"type\":\"text\"}"} and !adf:carry{json="{\"attrs\":{\"shortName\":\":a:\"},\"marks\":[{\"attrs\":{},\"type\":\"strong\"}],\"type\":\"emoji\"}"} diff --git a/corpus/round-trip/inline-nodes/text-break.json b/corpus/round-trip/inline-nodes/text-break.json new file mode 100644 index 0000000..f7e56f4 --- /dev/null +++ b/corpus/round-trip/inline-nodes/text-break.json @@ -0,0 +1,154 @@ +{ + "content": [ + { + "content": [ + { + "text": "Hello, ", + "type": "text" + }, + { + "text": "world", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "strong" + } + ], + "text": "Hello, ", + "type": "text" + }, + { + "marks": [ + { + "type": "strong" + } + ], + "text": "world", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "code" + } + ], + "text": "a", + "type": "text" + }, + { + "marks": [ + { + "type": "code" + } + ], + "text": "b", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "attrs": { + "href": "https://example.com/" + }, + "type": "link" + } + ], + "text": "a", + "type": "text" + }, + { + "marks": [ + { + "attrs": { + "href": "https://example.com/" + }, + "type": "link" + } + ], + "text": "b", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "text": "Line\n", + "type": "text" + }, + { + "text": "next", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "underline" + } + ], + "text": "a", + "type": "text" + }, + { + "marks": [ + { + "type": "underline" + } + ], + "text": "b", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "attrs": {}, + "type": "underline" + } + ], + "text": "a", + "type": "text" + }, + { + "marks": [ + { + "type": "underline" + } + ], + "text": "b", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/inline-nodes/text-break.md b/corpus/round-trip/inline-nodes/text-break.md new file mode 100644 index 0000000..71144ea --- /dev/null +++ b/corpus/round-trip/inline-nodes/text-break.md @@ -0,0 +1,13 @@ +Hello, !adf:textBreak{}world + +**Hello, !adf:textBreak{}world** + +`a`!adf:textBreak{}`b` + +[a!adf:textBreak{}b](https://example.com/) + +Line!adf:text{text="\n"}!adf:textBreak{}next + +!adf:underline[a!adf:textBreak{}b] + +!adf:underline[a]{attrs=empty}!adf:underline[b] diff --git a/corpus/round-trip/opaque-carry/carry-in-wrappers.md b/corpus/round-trip/opaque-carry/carry-in-wrappers.md index df32328..fb4de61 100644 --- a/corpus/round-trip/opaque-carry/carry-in-wrappers.md +++ b/corpus/round-trip/opaque-carry/carry-in-wrappers.md @@ -1,17 +1,15 @@ -> ```carry +> ```adf:blockCard > { > "attrs": { > "url": "https://example.com/quoted" -> }, -> "type": "blockCard" +> } > } > ``` -- ```carry +- ```adf:blockCard { "attrs": { "url": "https://example.com/listed" - }, - "type": "blockCard" + } } ``` diff --git a/corpus/round-trip/opaque-carry/code-block-children.json b/corpus/round-trip/opaque-carry/code-block-children.json new file mode 100644 index 0000000..b849d02 --- /dev/null +++ b/corpus/round-trip/opaque-carry/code-block-children.json @@ -0,0 +1,35 @@ +{ + "content": [ + { + "attrs": { + "language": "js" + }, + "content": [ + { + "marks": [ + { + "type": "strong" + } + ], + "text": "const a = 1", + "type": "text" + } + ], + "type": "codeBlock" + }, + { + "content": [ + { + "text": "a", + "type": "text" + }, + { + "type": "hardBreak" + } + ], + "type": "codeBlock" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/opaque-carry/code-block-children.md b/corpus/round-trip/opaque-carry/code-block-children.md new file mode 100644 index 0000000..4749c97 --- /dev/null +++ b/corpus/round-trip/opaque-carry/code-block-children.md @@ -0,0 +1,32 @@ +```adf:codeBlock +{ + "attrs": { + "language": "js" + }, + "content": [ + { + "marks": [ + { + "type": "strong" + } + ], + "text": "const a = 1", + "type": "text" + } + ] +} +``` + +```adf:codeBlock +{ + "content": [ + { + "text": "a", + "type": "text" + }, + { + "type": "hardBreak" + } + ] +} +``` diff --git a/corpus/round-trip/opaque-carry/unknown-block.md b/corpus/round-trip/opaque-carry/unknown-block.md index a88fe07..5469fb5 100644 --- a/corpus/round-trip/opaque-carry/unknown-block.md +++ b/corpus/round-trip/opaque-carry/unknown-block.md @@ -1,23 +1,21 @@ -```carry +```adf:blockCard { "attrs": { "url": "https://example.com/roadmap" - }, - "type": "blockCard" + } } ``` !adf:panel info The card below has no spelling yet. -```carry +```adf:embedCard { "attrs": { "layout": "wide", "url": "https://example.com/board", "width": 100 - }, - "type": "embedCard" + } } ``` !adf:/panel diff --git a/corpus/round-trip/opaque-carry/unknown-type-spelling.json b/corpus/round-trip/opaque-carry/unknown-type-spelling.json new file mode 100644 index 0000000..45a747a --- /dev/null +++ b/corpus/round-trip/opaque-carry/unknown-type-spelling.json @@ -0,0 +1,21 @@ +{ + "content": [ + { + "attrs": { + "url": "https://example.com/" + }, + "type": "has`tick" + }, + { + "type": "" + }, + { + "type": " padded" + }, + { + "type": "two words" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/opaque-carry/unknown-type-spelling.md b/corpus/round-trip/opaque-carry/unknown-type-spelling.md new file mode 100644 index 0000000..1988b18 --- /dev/null +++ b/corpus/round-trip/opaque-carry/unknown-type-spelling.md @@ -0,0 +1,24 @@ +```adf: +{ + "attrs": { + "url": "https://example.com/" + }, + "type": "has`tick" +} +``` + +```adf: +{ + "type": "" +} +``` + +```adf: +{ + "type": " padded" +} +``` + +```adf:two words +{} +``` diff --git a/src/adf/document.ts b/src/adf/document.ts index 490c5ac..370876c 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -1,6 +1,7 @@ import type { ConvertFault } from '../result.ts' import { isJsonValue, overNested, type JsonValue } from '../json-value.ts' import { largestNesting } from '../nesting.ts' +import { serializeCanonicalJson } from '../canonical-json.ts' export type AdfAttributes = { [key: string]: JsonValue } @@ -17,6 +18,8 @@ export type AdfNode = { type: string } +export type EmptyKey = 'attrs' | 'content' | 'marks' + export type AdfDocument = { content?: AdfNode[] type: 'doc' @@ -49,10 +52,26 @@ export function attributeNestingMessage(key: string, type: string): string { } export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean { - if (nodeMarks(node).length > 0 || node.text !== undefined) return false + if (nodeMarks(node).length > 0 || node.text !== undefined || emptyKeys(node).length > 0) return false return holdsOnly(nodeAttrs(node), attributes) } +export function emptyKeys(held: { attrs?: AdfAttributes; content?: AdfNode[]; marks?: AdfMark[] }): EmptyKey[] { + const keys: EmptyKey[] = [] + if (held.attrs !== undefined && Object.keys(held.attrs).length === 0) keys.push('attrs') + if (held.content?.length === 0) keys.push('content') + if (held.marks?.length === 0) keys.push('marks') + return keys +} + +export function identicalMark(left: AdfMark, right: AdfMark): boolean { + return marksKey([left]) === marksKey([right]) +} + +export function identicalMarks(left: AdfNode, right: AdfNode): boolean { + return marksKey(nodeMarks(left)) === marksKey(nodeMarks(right)) +} + // Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do. export function isAdfDocument(value: unknown): value is AdfDocument { const fault = adfDocumentFault(value) @@ -99,6 +118,13 @@ function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] { return true } +function marksKey(marks: readonly AdfMark[]): string { + return serializeCanonicalJson( + marks.map((mark) => (mark.attrs === undefined ? [mark.type] : [mark.type, mark.attrs])), + 'compact', + ) +} + function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined { const pending: AdfNode[] = [...nodes] while (pending.length > 0) { diff --git a/src/adf/editor-normal.ts b/src/adf/editor-normal.ts index b8a299b..cd37b1f 100644 --- a/src/adf/editor-normal.ts +++ b/src/adf/editor-normal.ts @@ -77,7 +77,7 @@ function mergesText(node: AdfNode): boolean { return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0 } -export function sameMarks(previous: AdfNode, node: AdfNode): boolean { +function sameMarks(previous: AdfNode, node: AdfNode): boolean { return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node)) } @@ -86,5 +86,5 @@ function marksKey(marks: readonly AdfMark[]): string { } function markKey(mark: AdfMark): string { - return `${mark.type} ${serializeCanonicalJson(nodeAttrs(mark), 'compact')}` + return `${mark.type} ${serializeCanonicalJson(normalAttributes(nodeAttrs(mark)) ?? {}, 'compact')}` } diff --git a/src/canonical-json.ts b/src/canonical-json.ts index 487dd99..a1fd6ec 100644 --- a/src/canonical-json.ts +++ b/src/canonical-json.ts @@ -18,7 +18,7 @@ export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling) const { depth, value: held } = next if (Array.isArray(held)) schedule(pending, '[', held.map((item) => ({ label: '', value: item })), ']', indent, depth) else if (held !== null && typeof held === 'object') schedule(pending, '{', objectMembers(held, indent), '}', indent, depth) - else text.push(JSON.stringify(held)) + else text.push(Object.is(held, -0) ? '-0' : JSON.stringify(held)) } return text.join('') } diff --git a/src/conformance/adf-property.test.ts b/src/conformance/adf-property.test.ts index 16de9d7..0061ea9 100644 --- a/src/conformance/adf-property.test.ts +++ b/src/conformance/adf-property.test.ts @@ -8,7 +8,6 @@ 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:' @@ -41,7 +40,7 @@ test('a generated document refuses to emit, or its markdown reads back to it', { if (!emitted.ok) return const read = markdownToAdf(emitted.value) assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`) - assert.deepEqual(toEditorNormal(read.value), document, `reading ${JSON.stringify(emitted.value)}`) + assert.deepEqual(read.value, document, `reading ${JSON.stringify(emitted.value)}`) }), propertyRuns(gateRuns), ) diff --git a/src/conformance/corpus.test.ts b/src/conformance/corpus.test.ts index 22ff052..86fe0f6 100644 --- a/src/conformance/corpus.test.ts +++ b/src/conformance/corpus.test.ts @@ -9,7 +9,6 @@ import { isAdfDocument } from '../adf/document.ts' import { isJsonValue } from '../json-value.ts' import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts' import { serializeCanonicalJson } from '../canonical-json.ts' -import { toEditorNormal } from '../adf/editor-normal.ts' const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus') const errorsRoot = join(corpusRoot, 'errors') @@ -97,7 +96,7 @@ for (const directory of roundTripDirectories) { assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8')) assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) - assert.deepEqual(toEditorNormal(result.value), expected) + assert.deepEqual(result.value, expected) }) } } @@ -125,12 +124,12 @@ for (const name of pairedNames(normalizationRoot, '.md', '.json')) { assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8')) assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) - assert.deepEqual(toEditorNormal(result.value), expected) + assert.deepEqual(result.value, expected) const emitted = adfToMarkdown(result.value) assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`) const again = markdownToAdf(emitted.value) assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`) - assert.deepEqual(toEditorNormal(again.value), expected) + assert.deepEqual(again.value, expected) }) } @@ -146,7 +145,7 @@ for (const name of names(realPayloadsRoot, '.json')) { assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`) const parsed = markdownToAdf(emitted.value) assert.ok(parsed.ok, parsed.ok ? '' : `${parsed.error.code}: ${parsed.error.message}`) - assert.deepEqual(toEditorNormal(parsed.value), payload) + assert.deepEqual(parsed.value, payload) }) } diff --git a/src/conformance/markdown-property.test.ts b/src/conformance/markdown-property.test.ts index 580dce9..64a435f 100644 --- a/src/conformance/markdown-property.test.ts +++ b/src/conformance/markdown-property.test.ts @@ -31,7 +31,6 @@ import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts' import { nodeContent, nodeMarks } from '../adf/document.ts' import { serializeCanonicalJson } from '../canonical-json.ts' import { textDirectiveName } from '../markdown/text-directive.ts' -import { toEditorNormal } from '../adf/editor-normal.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' type Choice = { arbitrary: Arbitrary; hostile?: true; weight: number } @@ -433,7 +432,7 @@ test('generated markdown refuses, or what it parses to refuses to emit, or its s if (holdsDirectiveShape(parsed.value)) directiveShaped += 1 const read = markdownToAdf(emitted.value) assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`) - assert.deepEqual(toEditorNormal(read.value), toEditorNormal(parsed.value), `reading ${JSON.stringify(emitted.value)}`) + assert.deepEqual(read.value, parsed.value, `reading ${JSON.stringify(emitted.value)}`) const respelled = adfToMarkdown(read.value) assert.ok(respelled.ok, respelled.ok ? '' : `${respelled.error.code}: ${respelled.error.message} — spelling ${JSON.stringify(emitted.value)} again`) assert.equal(respelled.value, emitted.value) diff --git a/src/conformance/property-harness.ts b/src/conformance/property-harness.ts index 5013bfa..cd935f8 100644 --- a/src/conformance/property-harness.ts +++ b/src/conformance/property-harness.ts @@ -11,7 +11,7 @@ import { blockNodes } from '../adf/block-nodes.ts' import { directivePrefix } from '../markdown/directive-syntax.ts' import { inlineNodes } from '../adf/inline-nodes.ts' import { markAttributes } from '../adf/mark-attributes.ts' -import { toEditorNormal } from '../adf/editor-normal.ts' +import { mergeAdjacentText } from '../adf/editor-normal.ts' type Positions = { block: AdfNode; inline: AdfNode } @@ -36,7 +36,11 @@ export function textOf(minLength: number): Arbitrary { const text = textOf(1) const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type)) -const numberValue = fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 }) +const numberValue = fc.oneof( + { arbitrary: fc.integer({ max: 10, min: -1 }), weight: 6 }, + { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 2 }, + { arbitrary: fc.constant(-0), weight: 1 }, +) // V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash (https://issues.chromium.org/issues/521080746); Bun is unaffected. const keyPiece = fc @@ -73,19 +77,37 @@ function heldAttributes(held: Readonly>): return attrs } +// An empty attrs, content or marks key, and adjacent text a reader would merge, stay occasional: each takes a spelling outside CommonMark. +// The copy gives fast-check's null-prototype records the prototype a parsed node has. +function occasionallyEmpty(arbitrary: Arbitrary): Arbitrary { + return fc.tuple(arbitrary, fc.nat({ max: 9 })).map(([held, roll]) => (roll === 0 ? { ...held } : withoutEmptyKeys(held))) +} + +function withoutEmptyKeys(held: T): T { + const kept = { ...held } + if (kept.attrs !== undefined && Object.keys(kept.attrs).length === 0) delete kept.attrs + if ('content' in kept && kept.content?.length === 0) delete kept.content + if ('marks' in kept && kept.marks?.length === 0) delete kept.marks + return kept +} + +function occasionallyApart(arbitrary: Arbitrary): Arbitrary { + return fc.tuple(arbitrary, fc.nat({ max: 5 })).map(([nodes, roll]) => (roll === 0 ? nodes : mergeAdjacentText(nodes))) +} + function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode { const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))] return { content: rows.map((content): AdfNode => ({ content, type: 'tableRow' })), type: 'table' } } -const mark: Arbitrary = fc.oneof( +const mark: Arbitrary = occasionallyEmpty(fc.oneof( { arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 }, { arbitrary: attributes({ color: 'string' }).map((attrs) => ({ attrs, type: 'backgroundColor' })), weight: 2 }, { arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 }, -) +)) const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type }) -const textNode = fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' })) +const textNode = occasionallyEmpty(fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' }))) const backtickRunNode = fc .record({ marks: fc.oneof(fc.constant([]), fc.constant([{ type: 'code' }]), marks), text: fc.string({ maxLength: 6, minLength: 1, unit: fc.constantFrom('`', '``', ' ', 'a') }) }) @@ -96,7 +118,7 @@ const autolinkTextNode = fc .map(({ href, marks: held }): AdfNode => ({ marks: [...held.filter((outer) => outer.type !== 'link'), { attrs: { href }, type: 'link' }], text: href, type: 'text' })) const inlineArbitraries = Object.entries(inlineNodes).map(([type, model]) => - fc.record({ attrs: attributes(model.attributes), marks }).map((held): AdfNode => ({ ...held, type })), + occasionallyEmpty(fc.record({ attrs: attributes(model.attributes), marks }).map((held): AdfNode => ({ ...held, type }))), ) function weighted(arbitraries: readonly Arbitrary[], weight: number): { arbitrary: Arbitrary; weight: number }[] { @@ -105,10 +127,10 @@ function weighted(arbitraries: readonly Arbitrary[], weight: number): { const positions = fc.letrec((tie) => { const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 }) - const inlineContent = fc.array(tie('inline'), { depthIdentifier, maxLength: 4 }) + const inlineContent = occasionallyApart(fc.array(tie('inline'), { depthIdentifier, maxLength: 4 })) const contentByModel = { block: blockContent, - code: fc.array(text.map((held): AdfNode => ({ text: held, type: 'text' })), { maxLength: 2 }), + code: occasionallyApart(fc.array(text.map((held): AdfNode => ({ text: held, type: 'text' })), { maxLength: 2 })), inline: inlineContent, none: fc.constant([]), } @@ -116,35 +138,40 @@ const positions = fc.letrec((tie) => { const blockArbitraries = Object.entries(blockNodes).map(([type, model]) => { const argument = blockArgument(type) const vocabulary: AttributeVocabulary = argument === undefined ? model.attributes : { ...model.attributes, [argument]: 'string' } - const node = fc.record({ attrs: attributes(vocabulary), content: contentByModel[model.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type })) + const node = occasionallyEmpty(fc.record({ attrs: attributes(vocabulary), content: contentByModel[model.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type }))) return { leaf: model.contentModel === 'code' || model.contentModel === 'none', node } }) - const unknownNode = fc - .record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType }) - .map((held): AdfNode => held) + const unknownNode = occasionallyEmpty( + fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType }), + ) const leafBlocks = blockArbitraries.filter((entry) => entry.leaf).map((entry) => entry.node) const containerBlocks = blockArbitraries.filter((entry) => !entry.leaf).map((entry) => entry.node) const misplacedWeight = 7 - const paragraph = fc.oneof({ arbitrary: inlineContent, weight: 3 }, { arbitrary: fc.array(backtickRunNode, { maxLength: 4, minLength: 2 }), weight: 1 }).map((content): AdfNode => ({ content, type: 'paragraph' })) + const paragraph = occasionallyEmpty( + fc.oneof({ arbitrary: inlineContent, weight: 3 }, { arbitrary: occasionallyApart(fc.array(backtickRunNode, { maxLength: 4, minLength: 2 })), weight: 1 }).map((content): AdfNode => ({ content, type: 'paragraph' })), + ) const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type })) const listItems = fc.array( - blockContent.map((content): AdfNode => ({ content, type: 'listItem' })), + occasionallyEmpty(blockContent.map((content): AdfNode => ({ content, type: 'listItem' }))), { depthIdentifier, maxLength: 3, minLength: 1 }, ) const flatCommonMarkShapes = [ - fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' })), + occasionallyEmpty(fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' }))), paragraph, fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable), ] const task = (type: string, content: Arbitrary) => - fc.record({ content, state: fc.constantFrom('DONE', 'TODO') }).map(({ content: held, state }): AdfNode => ({ attrs: { state }, content: held, type })) + occasionallyEmpty(fc.record({ content, state: fc.constantFrom('DONE', 'TODO') }).map(({ content: held, state }): AdfNode => ({ attrs: { state }, content: held, type }))) const taskItem = fc.oneof({ arbitrary: task('taskItem', inlineContent), weight: 3 }, { arbitrary: task('blockTaskItem', blockContent), weight: 1 }) const nestingCommonMarkShapes = [ - blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })), + occasionallyEmpty(blockContent.map((content): AdfNode => ({ content, type: 'blockquote' }))), fc.array(fc.oneof({ arbitrary: taskItem, weight: 3 }, { arbitrary: tie('block'), weight: 1 }), { depthIdentifier, maxLength: 3, minLength: 1 }).map((content): AdfNode => ({ content, type: 'taskList' })), listItems.map((content): AdfNode => ({ content, type: 'bulletList' })), fc - .record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) }) + .record({ + content: listItems, + order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 8 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 2 }, { arbitrary: fc.constant(-0), weight: 1 }), + }) .map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })), ] const flatBlocks = [...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)] @@ -167,7 +194,11 @@ const positions = fc.letrec((tie) => { } }) -export const adfDocument = fc.array(positions.block, { depthIdentifier, maxLength: 4, minLength: 1 }).map((content): AdfDocument => toEditorNormal({ content, type: 'doc', version: 1 })) +export const adfDocument = fc.oneof( + { arbitrary: fc.array(positions.block, { depthIdentifier, maxLength: 4, minLength: 1 }).map((content): AdfDocument => ({ content, type: 'doc', version: 1 })), weight: 30 }, + { arbitrary: fc.constant({ content: [], type: 'doc', version: 1 }), weight: 1 }, + { arbitrary: fc.constant({ type: 'doc', version: 1 }), weight: 1 }, +) export function propertyRuns(gateRuns: number): { gate: boolean; numRuns: number; seed?: number } { const deepRuns = env[deepRunsVariable] diff --git a/src/markdown/block-directive.ts b/src/markdown/block-directive.ts index c528e23..dc305bb 100644 --- a/src/markdown/block-directive.ts +++ b/src/markdown/block-directive.ts @@ -2,7 +2,7 @@ import type { AdfMark } from '../adf/document.ts' import type { BlockType } from '../adf/block-nodes.ts' import type { JsonValue } from '../json-value.ts' import { blockNodeModel } from '../adf/block-nodes.ts' -import { isAdfMark, nodeAttrs } from '../adf/document.ts' +import { isAdfMark } from '../adf/document.ts' import { serializeCanonicalJson } from '../canonical-json.ts' import { spellDirectiveOpener } from './directive-syntax.ts' @@ -14,6 +14,11 @@ const argumentByType = new Map( } satisfies Partial>), ) +export const documentName = 'doc' + +// spec/flavour.md, Directives: a document holding no content key, which the empty string cannot spell. +export const documentSpelling = spellDirectiveOpener(documentName, undefined, '{content=none}') + export const listBreakName = 'listBreak' export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '') @@ -25,17 +30,14 @@ export function blockArgument(type: string): string | undefined { } export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined { - if (name === listBreakName) return 'leaf' + if (name === listBreakName || name === documentName) return 'leaf' const model = blockNodeModel(name) if (model === undefined) return undefined return model.contentModel === 'none' ? 'leaf' : 'container' } export function markValues(marks: readonly AdfMark[]): JsonValue { - return marks.map((mark) => { - const attrs = nodeAttrs(mark) - return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type } - }) + return marks.map((mark) => (mark.attrs === undefined ? { type: mark.type } : { attrs: mark.attrs, type: mark.type })) } export function readMarkValues(value: JsonValue): AdfMark[] | undefined { diff --git a/src/markdown/code-language.ts b/src/markdown/code-language.ts index 223e75a..75ddf83 100644 --- a/src/markdown/code-language.ts +++ b/src/markdown/code-language.ts @@ -1,14 +1,12 @@ import type { JsonValue } from '../json-value.ts' -import { carryName } from './opaque-carry.ts' -import { holdsControlCharacter } from './commonmark/grammar.ts' -import { holdsEntityReference } from './commonmark/entity-references.ts' +import { carryFencePrefix } from './opaque-carry.ts' +import { infoStringCarries } from './commonmark/grammar.ts' export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' } // spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides. export function languageSlot(language: JsonValue | undefined): LanguageSlot { if (language === undefined) return { kind: 'none' } - if (typeof language !== 'string' || language === '' || language === carryName) return { kind: 'attribute' } - if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return { kind: 'attribute' } + if (typeof language !== 'string' || language.startsWith(carryFencePrefix) || !infoStringCarries(language)) return { kind: 'attribute' } return { info: language, kind: 'fence' } } diff --git a/src/markdown/commonmark/grammar.ts b/src/markdown/commonmark/grammar.ts index 53c9f1a..5d394e2 100644 --- a/src/markdown/commonmark/grammar.ts +++ b/src/markdown/commonmark/grammar.ts @@ -1,4 +1,4 @@ -import { readEntityReference, replacementCharacter } from './entity-references.ts' +import { holdsEntityReference, readEntityReference, replacementCharacter } from './entity-references.ts' export type LinePosition = 'first' | 'later' @@ -133,6 +133,11 @@ export function holdsControlCharacter(text: string): boolean { return controlCharacter.test(text) } +// What a backtick fence's info string reads back verbatim: escapes and entity references decode, and the edges trim. +export function infoStringCarries(text: string): boolean { + return text !== '' && !/[`\\]/.test(text) && !holdsControlCharacter(text) && text === text.trim() && !holdsEntityReference(text) +} + export function holdsNullCharacter(text: string): boolean { return nullCharacter.test(text) } diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 7c992da..c4f4cfd 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -139,7 +139,7 @@ export function spellStringAttribute(text: string): string { export function spellAttributeValue(value: VocabularyValue): string { if (value.kind === 'boolean') return `${value.value}` if (value.kind === 'json') return spellJsonAttribute(value.value) - if (value.kind === 'number') return spellStringAttribute(JSON.stringify(value.value)) + if (value.kind === 'number') return spellStringAttribute(serializeCanonicalJson(value.value, 'compact')) return spellStringAttribute(value.value) } diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index daf574f..b92e06c 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -6,14 +6,13 @@ import type { JsonValue } from '../../json-value.ts' import type { Result } from '../../result.ts' import { adfToMarkdown, markdownToAdf } from '../../index.ts' import { largestNesting } from '../../nesting.ts' -import { toEditorNormal } from '../../adf/editor-normal.ts' function document(...content: AdfNode[]): AdfDocument { return { content, type: 'doc', version: 1 } } function paragraph(...content: AdfNode[]): AdfNode { - return { content, type: 'paragraph' } + return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' } } function code(result: Result): string { @@ -168,21 +167,24 @@ test('parts two adjacent lists of the same kind, the marker spelling being what const nested: AdfNode = { content: [{ content: [list, list], type: 'listItem' }], type: 'bulletList' } assert.equal(markdown(adfToMarkdown(document(nested))), '- - x\n\n !adf:listBreak\n\n - x\n') const carried: AdfNode = { ...list, attrs: { unknown: 'x' } } - assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```carry\n')) + assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```adf:bulletList\n')) assert.ok(markdown(adfToMarkdown(document(carried, list))).endsWith('```\n\n- x\n')) - assert.ok(markdown(adfToMarkdown(document(list, carried))).startsWith('- x\n\n```carry\n')) + assert.ok(markdown(adfToMarkdown(document(list, carried))).startsWith('- x\n\n```adf:bulletList\n')) }) -test('carries a node type no section spells', () => { - assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```carry\n{\n "type": "blockCard"\n}\n```\n') - assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```carry\n{\n "type": "toString"\n}\n```\n') +test('carries a node type no section spells, the fence naming the type wherever an info string carries it', () => { + assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```adf:blockCard\n{}\n```\n') + assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```adf:toString\n{}\n```\n') assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), '!adf:carry{json="{\\"type\\":\\"blockCard\\"}"}\n') - assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```carry\n{\n "text": "x",\n "type": "text"\n}\n```\n') - assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```carry\n{\n "type": "hardBreak"\n}\n```\n') + assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```adf:text\n{\n "text": "x"\n}\n```\n') + assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```adf:hardBreak\n{}\n```\n') + assert.equal(markdown(adfToMarkdown(document({ type: 'a&b' }))), '```adf:\n{\n "type": "a&b"\n}\n```\n') + assert.equal(markdown(adfToMarkdown(document({ type: 'a\\b' }))), '```adf:\n{\n "type": "a\\\\b"\n}\n```\n') }) -test('spells the code block whose language is the reserved info string', () => { - assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '!adf:codeBlock {language=carry}\n```\n```\n!adf:/codeBlock\n') +test('spells the code block whose language opens with the reserved info string prefix', () => { + assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf:x' }, type: 'codeBlock' }))), '!adf:codeBlock {language="adf:x"}\n```\n```\n!adf:/codeBlock\n') + assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '```carry\n```\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), '```adf\n```\n') }) @@ -208,21 +210,17 @@ test('refuses a carried node nested deeper than the levels its position leaves', assert.equal(code(adfToMarkdown(document(quoted))), 'unsupported-nesting-depth') }) -test('refuses a node whose content model the canonical form cannot emit', () => { - assert.equal( - markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), - 'unsupported-node-shape: a codeBlock holds plain text nodes only: this paragraph node is not one', - ) - assert.equal( - markdown(adfToMarkdown(document({ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }], type: 'codeBlock' }))), - 'unsupported-node-shape: a codeBlock holds plain text nodes only: this text node is not one', - ) +test('carries a code block holding a node no fence holds', () => { + assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), '```adf:codeBlock\n{\n "content": [\n {\n "type": "paragraph"\n }\n ]\n}\n```\n') + for (const child of [{ attrs: {}, text: 'x', type: 'text' }, { content: [], text: 'x', type: 'text' }, { marks: [], text: 'x', type: 'text' }, { content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }]) { + assert.ok(markdown(adfToMarkdown(document({ content: [child], type: 'codeBlock' }))).startsWith('```adf:codeBlock\n'), JSON.stringify(child)) + } }) test('spells a list its own content shape cannot hold as a directive', () => { assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), '!adf:bulletList\n!adf:paragraph\n!adf:/paragraph\n!adf:/bulletList\n') assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), '!adf:bulletList\n!adf:/bulletList\n') - assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), '!adf:orderedList {order=2}\n!adf:/orderedList\n') + assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), '!adf:orderedList {content=empty order=2}\n!adf:/orderedList\n') }) test('spells an ordered list no marker fits as a directive', () => { @@ -334,7 +332,7 @@ test('refuses a node carrying one mark type twice', () => { }) test('parts a nested list the tight spelling would swallow from the block above it', () => { - const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' }) + const item = (...content: AdfNode[]): AdfNode => (content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' }) const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' }) const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' }) const ordered: AdfNode = { attrs: { order: 2 }, content: [item(text('b'))], type: 'orderedList' } @@ -345,7 +343,7 @@ test('parts a nested list the tight spelling would swallow from the block above const list: AdfNode = { content: [item(text('b'))], type: 'bulletList' } const panel: AdfNode = { attrs: { panelType: 'info' }, content: [text('p')], type: 'panel' } assert.equal(markdown(adfToMarkdown(outer(panel, list))), '- !adf:panel info\n p\n !adf:/panel\n\n - b\n') - assert.ok(markdown(adfToMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```carry\n')) + assert.ok(markdown(adfToMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```adf:bulletList\n')) }) test('refuses marks and attributes nested deeper than the emitter carries', () => { @@ -372,7 +370,7 @@ test('refuses marks and attributes nested deeper than the emitter carries', () = assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message) const read = markdownToAdf(spelled.value) assert.ok(read.ok, read.ok ? '' : read.error.message) - assert.deepEqual(toEditorNormal(read.value), document(node)) + assert.deepEqual(read.value, document(node)) } assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em')) @@ -442,7 +440,7 @@ test('escapes a hyphen underline a hard break would expose', () => { }) test('spells a list item whose marker completes a thematic break as a directive', () => { - const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' }) + const item = (...content: AdfNode[]): AdfNode => (content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' }) assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n') const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' } assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n') @@ -469,10 +467,7 @@ test('refuses the characters CommonMark rewrites', () => { ) assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\u0000b', type: 'text' })))), 'unspellable-character') assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))), 'unspellable-character') - assert.equal( - markdown(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), - 'unsupported-node-shape: a codeBlock holds plain text nodes only: this text node is not one', - ) + assert.equal(markdown(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape: a text node holds text: this one has none') }) test('refuses a text node the spelling would empty out', () => { @@ -501,11 +496,11 @@ test('pads a code span whose edges CommonMark would strip', () => { assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: ' \t ', type: 'text' })))), '` \t `\n') }) -test('spells one code span over a run of code-marked nodes', () => { +test('spells a code span per code-marked node, parted by the text break', () => { const code_ = { type: 'code' } assert.equal( markdown(adfToMarkdown(document(paragraph({ marks: [code_], text: 'a', type: 'text' }, { marks: [code_], text: 'b', type: 'text' })))), - '`ab`\n', + '`a`!adf:textBreak{}`b`\n', ) }) @@ -545,7 +540,7 @@ test('emits an empty list item without trailing whitespace', () => { test('spells a block directive as its node type, arg and attributes', () => { const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' }) assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), '!adf:panel warning\nx\n!adf:/panel\n') - assert.equal(markdown(adfToMarkdown(panel({}))), '!adf:panel\nx\n!adf:/panel\n') + assert.equal(markdown(adfToMarkdown(panel({}))), '!adf:panel {attrs=empty}\nx\n!adf:/panel\n') assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), '!adf:caption\nx\n!adf:/caption\n') assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '!adf:caption\n!adf:/caption\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '!adf:syncBlock {localId=a}\n') @@ -553,20 +548,20 @@ test('spells a block directive as its node type, arg and attributes', () => { test('carries a directive attribute no section spells', () => { const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node))) - assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```carry\n{\n "attrs": {\n "rounded": true\n },\n "type": "panel"\n}\n```\n') - assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```carry\n{\n "attrs": {\n "toString": "x"\n },\n "type": "panel"\n}\n```\n') - assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```carry\n{\n "attrs": {\n "localId": 4\n },\n "type": "panel"\n}\n```\n') + assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "rounded": true\n }\n}\n```\n') + assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "toString": "x"\n }\n}\n```\n') + assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "localId": 4\n }\n}\n```\n') assert.equal( carried({ attrs: { width: '50' }, type: 'layoutColumn' }), - '```carry\n{\n "attrs": {\n "width": "50"\n },\n "type": "layoutColumn"\n}\n```\n', + '```adf:layoutColumn\n{\n "attrs": {\n "width": "50"\n }\n}\n```\n', ) assert.equal( carried({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }), - '```carry\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n },\n "type": "table"\n}\n```\n', + '```adf:table\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n }\n}\n```\n', ) assert.equal( carried({ content: [{ attrs: { alt: 4 }, type: 'media' }], type: 'mediaGroup' }), - '!adf:mediaGroup\n```carry\n{\n "attrs": {\n "alt": 4\n },\n "type": "media"\n}\n```\n!adf:/mediaGroup\n', + '!adf:mediaGroup\n```adf:media\n{\n "attrs": {\n "alt": 4\n }\n}\n```\n!adf:/mediaGroup\n', ) }) @@ -574,15 +569,16 @@ test('carries an arg slot value no bare token spells', () => { const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node))) assert.equal( carried({ attrs: { panelType: 'extra info' }, type: 'panel' }), - '```carry\n{\n "attrs": {\n "panelType": "extra info"\n },\n "type": "panel"\n}\n```\n', + '```adf:panel\n{\n "attrs": {\n "panelType": "extra info"\n }\n}\n```\n', ) - assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```carry\n{\n "attrs": {\n "state": 2\n },\n "type": "taskItem"\n}\n```\n') + assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```adf:taskItem\n{\n "attrs": {\n "state": 2\n }\n}\n```\n') }) test('carries a block node mark in the reserved attribute', () => { const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' }) assert.equal(markdown(adfToMarkdown(section({ type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n') - assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n') + assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"attrs\\":{},\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n') + assert.equal(markdown(adfToMarkdown(section())), '!adf:layoutSection {marks=empty}\n!adf:/layoutSection\n') }) test('refuses the content a directive body has no room for', () => { @@ -604,7 +600,7 @@ test('separates blocks in a container body by a blank line only where a directiv test('spells the image form for exactly the centered external media shape', () => { const url = 'https://example.com/moon.png' const single = (attrs: AdfAttributes, ...content: AdfNode[]): AdfDocument => - document({ attrs: { layout: 'center' }, content: [{ attrs, content, type: 'media' }], type: 'mediaSingle' }) + document({ attrs: { layout: 'center' }, content: [content.length === 0 ? { attrs, type: 'media' } : { attrs, content, type: 'media' }], type: 'mediaSingle' }) assert.equal(markdown(adfToMarkdown(single({ alt: 'The moon', type: 'external', url }))), `![The moon](${url})\n`) assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `![](${url})\n`) assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${url})\n`) @@ -701,7 +697,8 @@ test('spells the directive marks around the longest run they cover', () => { const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content)))) const underline: AdfMark = { type: 'underline' } assert.equal(emitted(marked('x', underline)), '!adf:underline[x]\n') - assert.equal(emitted(marked('a', underline), marked('b', underline)), '!adf:underline[ab]\n') + assert.equal(emitted(marked('a', underline), marked('b', underline)), '!adf:underline[a!adf:textBreak{}b]\n') + assert.equal(emitted(marked('a', { attrs: {}, type: 'underline' }), marked('b', underline)), '!adf:underline[a]{attrs=empty}!adf:underline[b]\n') assert.equal(emitted(marked('x', { attrs: { type: 'sub' }, type: 'subsup' })), '!adf:subsup[x]{type=sub}\n') assert.equal(emitted(marked('x', { attrs: { color: '#ae2e24' }, type: 'textColor' })), '!adf:textColor[x]{color="#ae2e24"}\n') assert.equal(emitted(marked('x', { attrs: { color: '#091e42', size: 2 }, type: 'border' })), '!adf:border[x]{color="#091e42" size=2}\n') @@ -746,5 +743,5 @@ test('carries whitespace CommonMark strips in the reserved text directive', () = test("joins a mark run's segments as a walk rather than as one call's arguments", () => { const run = Array.from({ length: 200000 }, (): AdfNode => ({ marks: [{ type: 'strong' }], text: 'a', type: 'text' })) - assert.equal(markdown(adfToMarkdown(document({ content: run, type: 'paragraph' }))), `**${'a'.repeat(200000)}**\n`) + assert.equal(markdown(adfToMarkdown(document({ content: run, type: 'paragraph' }))), `**${Array.from({ length: 200000 }, () => 'a').join('!adf:textBreak{}')}**\n`) }) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 0d41fe4..1a63f78 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -1,16 +1,16 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { BlockNodeModel } from '../../adf/block-nodes.ts' import type { Flavour } from '../plain-conventions.ts' -import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' +import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts' import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain-conventions.ts' -import { blockDirectiveForm, listBreakSpelling } from '../block-directive.ts' +import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts' import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts' import { carriedBlock } from '../opaque-carry.ts' import { emitInlineLine } from './inline-line.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { fencedCodeBlock } from '../commonmark/backtick-runs.ts' import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts' -import { languageSlot } from '../code-language.ts' +import { languageSlot, type LanguageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { spellBlockDirectiveOpener } from './block-directive-spelling.ts' import { spellDirectiveCloser, spellDirectiveOpener } from '../directive-syntax.ts' @@ -40,7 +40,8 @@ export function writeMarkdown(document: AdfDocument, flavour: Flavour): Result 0) return undefined + return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', only))) } +// spec/flavour.md, The CommonMark blocks: one fence per text node, the language on each; with no fence to carry it, the attribute does. function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result { - const slot = languageSlot(nodeAttrs(node)['language']) + const texts = fencedTexts(node, path) + if (!texts.ok) return texts + if (texts.value === undefined) return commonMarkLine(carriedBlock(node, path, depth)) + const slot: LanguageSlot = texts.value.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language']) const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language']) if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth)) if (!opener.ok) return opener - const text = codeBlockText(node, path) - if (!text.ok) return text - return success(directivePair(node, opener.value, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value))) + const info = slot.kind === 'fence' ? slot.info : '' + return success(directivePair(node, opener.value, texts.value.map((text) => fencedCodeBlock(info, text)).join('\n'))) } -function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result { - let text = '' - for (const [index, child] of nodeContent(node).entries()) { +// The text each fence holds, or `undefined` where a child is no plain text node, which the carry holds instead. +function fencedTexts(node: AdfNode, path: ConvertErrorPath): Result { + if (node.content === undefined) return success(['']) + if (node.content.some((child) => child.type !== 'text' || child.attrs !== undefined || child.content !== undefined || child.marks !== undefined)) return success(undefined) + const texts: string[] = [] + for (const [index, child] of node.content.entries()) { const childPath = [...path, 'content', index] - if ( - child.type !== 'text' || - typeof child.text !== 'string' || - child.text === '' || - nodeContent(child).length > 0 || - nodeMarks(child).length > 0 || - Object.keys(nodeAttrs(child)).length > 0 - ) { - return failure('unsupported-node-shape', `a codeBlock holds plain text nodes only: this ${child.type} node is not one`, childPath) - } + if (typeof child.text !== 'string' || child.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', childPath) if (/\r/.test(child.text)) return failure('unspellable-character', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', childPath) if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath) - text += child.text + texts.push(child.text) } - return success(text) + return success(texts) } function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result | undefined { @@ -340,7 +340,7 @@ function directiveItems(items: readonly WalkedItem[]): PlacedBlock[] { function listStart(node: AdfNode, items: number): number | undefined { if (node.type !== 'orderedList') return 0 const start = nodeAttrs(node)['order'] - if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined + if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || Object.is(start, -0) || start > largestListMarker) return undefined return start + items - 1 > largestListMarker ? undefined : start } diff --git a/src/markdown/emit/block-directive-spelling.ts b/src/markdown/emit/block-directive-spelling.ts index 4d4a964..9c42408 100644 --- a/src/markdown/emit/block-directive-spelling.ts +++ b/src/markdown/emit/block-directive-spelling.ts @@ -5,6 +5,7 @@ import { blockArgument, markValues, marksAttribute } from '../block-directive.ts import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' import { overNested } from '../../json-value.ts' +import { spellEmptyKeys } from '../empty-keys.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, spelledByBody: readonly string[] = []): Result | undefined { @@ -14,7 +15,7 @@ export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody] const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, spelled) if (pairs === undefined) return undefined - const spelledPairs = spellVocabulary(pairs) + const spelledPairs = [...spellVocabulary(pairs), ...spellEmptyKeys(node)] const marks = nodeMarks(node) if (marks.length > 0) { const values = markValues(marks) diff --git a/src/markdown/emit/inline-directive-spelling.ts b/src/markdown/emit/inline-directive-spelling.ts index 0680214..69f8e76 100644 --- a/src/markdown/emit/inline-directive-spelling.ts +++ b/src/markdown/emit/inline-directive-spelling.ts @@ -2,9 +2,10 @@ import type { AdfNode } from '../../adf/document.ts' import type { InlineNodeModel } from '../../adf/inline-nodes.ts' import { nodeAttrs } from '../../adf/document.ts' import { spellAttributes, spellVocabulary } from '../directive-syntax.ts' +import { spellEmptyKeys } from '../empty-keys.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' export function spellInlineNodeAttributes(node: AdfNode, model: InlineNodeModel): string | undefined { const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, model.textAttribute === undefined ? [] : [model.textAttribute]) - return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) + return pairs === undefined ? undefined : spellAttributes([...spellVocabulary(pairs), ...spellEmptyKeys(node)]) } diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 6e719e0..5d4c3d2 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -6,14 +6,14 @@ import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, import { carriedInline } from '../opaque-carry.ts' import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts' import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts' +import { emptyKeys, identicalMark, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { highlightDelimiter } from '../plain-conventions.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { largestNesting } from '../../nesting.ts' import { longestBacktickRun } from '../commonmark/backtick-runs.ts' -import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' -import { sameMark } from '../../adf/editor-normal.ts' +import { readsAsOne, textBreakSpelling } from '../text-break.ts' import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' import { spellTextDirective } from '../text-directive.ts' @@ -186,6 +186,7 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c const runs = inlineRuns(nodes, depth, firstIndex, context.carried) const segments: InlineSegment[] = [] for (const [offset, run] of runs.entries()) { + if (partsText(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling)) const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 } const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext) if (!emitted.ok) return emitted @@ -206,12 +207,18 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number continue } const previous = runs[runs.length - 1] - if (previous?.kind === 'marked' && sameMark(previous.mark, mark)) previous.nodes.push(node) + if (previous?.kind === 'marked' && identicalMark(previous.mark, mark)) previous.nodes.push(node) else runs.push({ index, kind: 'marked', mark, nodes: [node] }) } return runs } +function partsText(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet): boolean { + if (previous?.kind !== 'plain' || run.kind !== 'plain') return false + if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false + return readsAsOne(previous.node, run.node) +} + function nodePath(context: InlineContext, index: number): ConvertErrorPath { return [...context.path, 'content', index] } @@ -261,7 +268,7 @@ function emitInlineDirective(node: AdfNode, model: InlineNodeModel, index: numbe } function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result { - if (Object.keys(nodeAttrs(node)).length > 0) return success({ carry: { first: index, last: index } }) + if (node.attrs !== undefined || emptyKeys(node).length > 0) return success({ carry: { first: index, last: index } }) if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path) if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path) if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path) @@ -277,7 +284,7 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, if (mark.type === 'backgroundColor' && context.flavour === 'plain') return emitHighlight(nodes, depth, range, context) const spelling = markSpelling(mark.type) if (spelling === undefined) return success({ carry: range }) - const attributes = spellMarkAttributes(mark, spelling.attributes) + const attributes = spellMarkAttributes(mark, spelling) if (attributes === undefined) return success({ carry: range }) if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path) if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context) @@ -312,19 +319,20 @@ function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRang }) } +// Each node its own span: CommonMark reads two adjacent text nodes in one span back as one. function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result { - let text = '' + const spans: string[] = [] for (const node of nodes) { - if (node.type !== 'text' || nodeMarks(node).length !== depth + 1) return success({ carry: range }) - if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path) + if (node.type !== 'text' || nodeMarks(node).length !== depth + 1 || node.attrs !== undefined || emptyKeys(node).length > 0) return success({ carry: range }) + const { text } = node + if (typeof text !== 'string' || text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path) if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path) - text += node.text + if (/[\n\r]/.test(text)) return success({ carry: range }) + if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path) + const fence = '`'.repeat(longestBacktickRun(text) + 1) + spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`) } - if (/[\n\r]/.test(text)) return success({ carry: range }) - if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path) - const fence = '`'.repeat(longestBacktickRun(text) + 1) - const padded = needsPadding(text) ? ` ${text} ` : text - return success({ segments: [syntax(`${fence}${padded}${fence}`)] }) + return success({ segments: [syntax(spans.join(textBreakSpelling))] }) } function needsPadding(text: string): boolean { diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts index 7f6ac56..8308214 100644 --- a/src/markdown/emit/plain-reduction.test.ts +++ b/src/markdown/emit/plain-reduction.test.ts @@ -177,7 +177,7 @@ test('keeps the CommonMark blocks in their spelling and drops their attributes a assert.equal(plain(node('paragraph', localId, text('x')), node('heading', { level: 2, localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de' }, text('h'))), 'x\n\n## h\n') assert.equal(plain({ attrs: localId, content: [said('q')], marks: [{ type: 'breakout' }], type: 'blockquote' }), '> q\n') assert.equal(plain(node('codeBlock', { language: 'ts', wrap: true }, text('a\r\nb\u0000'))), '```ts\na\nb\n```\n') - assert.equal(plain(node('codeBlock', { language: 'carry' }, text('a'), { type: 'hardBreak' }, text('b', strong))), '```\na\nb\n```\n') + assert.equal(plain(node('codeBlock', { language: 'adf:x' }, text('a'), { type: 'hardBreak' }, text('b', strong))), '```\na\nb\n```\n') assert.equal(plain(node('codeBlock', {})), '```\n```\n') assert.equal(plain(node('rule', { color: '#000' })), '---\n') assert.equal(plain(node('orderedList', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', order: 3 }, item(said('c')))), '3. c\n') diff --git a/src/markdown/emit/plain-reduction.ts b/src/markdown/emit/plain-reduction.ts index 145ecf4..593c4fe 100644 --- a/src/markdown/emit/plain-reduction.ts +++ b/src/markdown/emit/plain-reduction.ts @@ -8,6 +8,7 @@ import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { taskMarker } from '../plain-conventions.ts' +import { toEditorNormal } from '../../adf/editor-normal.ts' // depth: the level the node reduced stands at, counted as the emitter counts it. type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath } @@ -50,8 +51,9 @@ 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}`, []) - const blocks = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] }) - return blocks.ok ? success({ content: blocks.value, type: 'doc', version: 1 }) : blocks + // The plain flavour is lossy: it reads and writes editor-normal ADF, whose shapes CommonMark spells. + 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 } function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result { @@ -262,7 +264,8 @@ function listItem(blocks: Result): Result { // A list item's first line reads as no rule and holds no line of spaces alone: the rule and the spaces give way. function itemOf(blocks: readonly AdfNode[]): AdfNode { const rules = blocks.findIndex((block) => block.type !== 'rule') - return { content: blankedCode(blocks.slice(rules === -1 ? blocks.length : rules)), type: 'listItem' } + const content = blankedCode(blocks.slice(rules === -1 ? blocks.length : rules)) + return content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' } } function blankedCode(blocks: readonly AdfNode[]): AdfNode[] { diff --git a/src/markdown/empty-keys.ts b/src/markdown/empty-keys.ts new file mode 100644 index 0000000..b59008d --- /dev/null +++ b/src/markdown/empty-keys.ts @@ -0,0 +1,30 @@ +import type { DirectiveAttributes, DirectiveValue, Read } from './directive-syntax.ts' +import type { EmptyKey } from '../adf/document.ts' +import { emptyKeys } from '../adf/document.ts' +import { unsupportedNodeShape } from './directive-syntax.ts' + +type EmptyKeysRead = { empty: ReadonlySet; rest: Map } + +const emptyValue = 'empty' + +// spec/flavour.md, Attributes: no attribute value spells an empty object or array. +export function spellEmptyKeys(held: Parameters[0]): [string, string][] { + return emptyKeys(held).map((key) => [key, emptyValue]) +} + +export function spellsEmpty(value: DirectiveValue | undefined): boolean { + return value?.spelling === emptyValue +} + +export function readEmptyKeys(attributes: DirectiveAttributes, keys: readonly EmptyKey[]): Read { + const rest = new Map(attributes) + const empty = new Set() + for (const key of keys) { + const spelled = rest.get(key) + if (spelled === undefined) continue + if (!spellsEmpty(spelled)) return { fault: unsupportedNodeShape(`the reserved key ${key} reads ${key}=${emptyValue} alone: this one spells ${key}=${spelled.spelling}`) } + empty.add(key) + rest.delete(key) + } + return { value: { empty, rest } } +} diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts index a885262..d9a814c 100644 --- a/src/markdown/mark-spellings.ts +++ b/src/markdown/mark-spellings.ts @@ -7,6 +7,7 @@ import { holdsEntityReference } from './commonmark/entity-references.ts' import { isAutolink } from './commonmark/grammar.ts' import { isMarkType, markAttributes } from '../adf/mark-attributes.ts' import { nodeAttrs, nodeMarks } from '../adf/document.ts' +import { spellEmptyKeys } from './empty-keys.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string } @@ -55,7 +56,10 @@ function isBareLink(nodes: readonly AdfNode[], href: string, marksInside: number return nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === marksInside } -export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined { - const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, []) - return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) +// `undefined` where the spelling holds no such attrs: only a directive spells attrs=empty. +export function spellMarkAttributes(mark: AdfMark, spelling: MarkSpelling): string | undefined { + const pairs = vocabularyPairs(nodeAttrs(mark), spelling.attributes, []) + const empty = spellEmptyKeys(mark) + if (pairs === undefined || (empty.length > 0 && spelling.kind !== 'directive')) return undefined + return spellAttributes([...spellVocabulary(pairs), ...empty]) } diff --git a/src/markdown/opaque-carry.ts b/src/markdown/opaque-carry.ts index 0e03467..0a113d6 100644 --- a/src/markdown/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -1,32 +1,41 @@ import type { AdfNode } from '../adf/document.ts' import type { DirectiveSpan, Read } from './directive-syntax.ts' import type { JsonSpelling } from '../canonical-json.ts' +import type { JsonValue } from '../json-value.ts' import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' import { fencedCodeBlock } from './commonmark/backtick-runs.ts' +import { infoStringCarries } from './commonmark/grammar.ts' import { isAdfNode } from '../adf/document.ts' import { isJsonValue, nestingDepth, overNested } from '../json-value.ts' import { largestNesting } from '../nesting.ts' import { malformedDirective, readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' import { serializeCanonicalJson } from '../canonical-json.ts' +export const carryFencePrefix = 'adf:' + export const carryName = 'carry' const jsonAttribute = 'json' +// spec/flavour.md, The opaque carry: the info string names the type wherever it carries the type back. export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<{ headroom: number; text: string }> { - const json = carriedJson(node, 'two-space', path, largestNesting - depth) + const { type, ...untyped } = node + const named = infoStringCarries(type) + const json = carriedJson(node, named ? untyped : node, 'two-space', path, largestNesting - depth) if (!json.ok) return json - return success({ headroom: json.value.headroom, text: fencedCodeBlock(carryName, json.value.json) }) + return success({ headroom: json.value.headroom, text: fencedCodeBlock(`${carryFencePrefix}${named ? type : ''}`, json.value.json) }) } export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result { - const json = carriedJson(node, 'compact', path, largestNesting) + const json = carriedJson(node, node, 'compact', path, largestNesting) if (!json.ok) return json return success(spellInlineLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value.json)]]))) } -export function readCarriedBlock(body: string, depth: number): Read { - return readCarriedJson(body, 'two-space', largestNesting - depth) +export function readCarriedBlock(type: string, body: string, depth: number): Read { + const read = readCarriedJson(body, 'two-space', largestNesting - depth, type) + if (read.fault !== undefined || type !== '' || !infoStringCarries(read.value.type)) return read + return { fault: unsupportedNodeShape(`the carry fence names a type its info string carries: spell it ${carryFencePrefix}${read.value.type}`) } } export function readCarriedInline(span: DirectiveSpan): Read | undefined { @@ -36,28 +45,38 @@ export function readCarriedInline(span: DirectiveSpan): Read | undefine return readCarriedJson(spelled.value, 'compact', largestNesting) } -function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<{ headroom: number; json: string }> { +function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<{ headroom: number; json: string }> { const headroom = levels - nestingDepth(node) - if (!isJsonValue(node) || headroom < 0) { + if (!isJsonValue(spelled) || headroom < 0) { return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path) } - return success({ headroom, json: serializeCanonicalJson(node, spelling) }) + return success({ headroom, json: serializeCanonicalJson(spelled, spelling) }) } -function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number): Read { +// `type` is the one the fence's info string names, `undefined` for the inline carry. +function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number, type?: string): Read { const parsed = parseJsonText(raw) if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') } const { value } = parsed if (!isJsonValue(value)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') } - if (overNested(value, levels)) { + const typed = type === undefined || type === '' ? { value } : typedValue(value, type) + if (typed.fault !== undefined) return typed + const held = typed.value + if (overNested(held, levels)) { return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${levels} levels its position leaves` } } } if (serializeCanonicalJson(value, spelling) !== raw) { const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted' return { fault: unsupportedNodeShape(`the opaque carry spells its node's JSON canonically: ${shape}`) } } - if (!isAdfNode(value)) return { fault: unsupportedNodeShape("the opaque carry holds one ADF node's JSON: this JSON is no ADF node") } - return { value } + if (!isAdfNode(held)) return { fault: unsupportedNodeShape("the opaque carry holds one ADF node's JSON: this JSON is no ADF node") } + return { value: held } +} + +function typedValue(value: JsonValue, type: string): Read { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return { value } + if ('type' in value) return { fault: unsupportedNodeShape(`the ${carryFencePrefix}${type} fence names its node's type: this JSON holds a type as well`) } + return { value: { ...value, type } } } function parseJsonText(raw: string): { value: unknown } | undefined { diff --git a/src/markdown/parse/directive-marks.ts b/src/markdown/parse/directive-marks.ts index f1b7df6..5d42ecd 100644 --- a/src/markdown/parse/directive-marks.ts +++ b/src/markdown/parse/directive-marks.ts @@ -3,8 +3,9 @@ import type { ConvertFault } from '../../result.ts' import type { DirectiveAttributes } from '../directive-syntax.ts' import type { MarkSpelling } from '../mark-spellings.ts' import { directivePrefix } from '../directive-syntax.ts' -import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' +import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { markSpelling } from '../mark-spellings.ts' +import { readEmptyKeys } from '../empty-keys.ts' import { readVocabulary } from './directive-attributes.ts' export function readDirectiveMark(name: string, attributes: DirectiveAttributes, path: ConvertErrorPath): Result | undefined { @@ -12,8 +13,11 @@ export function readDirectiveMark(name: string, attributes: DirectiveAttributes, if (spelling === undefined) return undefined const markdown = markdownForm(spelling) if (markdown !== undefined) return failure('unsupported-node-shape', `${name} is spelled ${markdown}, never as a directive`, path) - const attrs = readVocabulary(name, attributes, spelling.attributes, undefined, path) + const empty = readEmptyKeys(attributes, ['attrs']) + if (empty.fault !== undefined) return faulted(empty.fault, path) + const attrs = readVocabulary(name, empty.value.rest, spelling.attributes, undefined, path) if (!attrs.ok) return attrs + if (empty.value.empty.has('attrs')) return Object.keys(attrs.value).length === 0 ? success({ attrs: {}, type: name }) : failure('unsupported-node-shape', `${name} spells attrs=empty beside an attribute it holds`, path) return success(Object.keys(attrs.value).length === 0 ? { type: name } : { attrs: attrs.value, type: name }) } diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index 7235b69..c738fb1 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -1,4 +1,4 @@ -import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts' +import type { AdfAttributes, AdfMark, AdfNode, EmptyKey } from '../../adf/document.ts' import type { BlockNodeModel } from '../../adf/block-nodes.ts' import type { ConvertFault } from '../../result.ts' import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts' @@ -7,15 +7,18 @@ import { attributeNestingMessage, nodeAttrs, nodeContent, nodeMarks } from '../. import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts' import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts' import { blockNodeModel } from '../../adf/block-nodes.ts' -import { carryName } from '../opaque-carry.ts' +import { carryFencePrefix, carryName } from '../opaque-carry.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { inlineMarkSpellingFault } from './directive-marks.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' +import { readEmptyKeys, spellsEmpty } from '../empty-keys.ts' import { readVocabulary } from './directive-attributes.ts' import { slotLineEndingFault } from '../directive-syntax.ts' +import { textBreakName } from '../text-break.ts' import { textDirectiveName } from '../text-directive.ts' -export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; node: AdfNode } +// `emptyContent` is whether the directive spells content=empty, which holds no body. +export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; emptyContent: boolean; node: AdfNode } export function readBlockDirectiveNode( name: string, @@ -24,12 +27,15 @@ export function readBlockDirectiveNode( path: ConvertErrorPath, ): Result { if (name === carryName) { - return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryName} fence`, path) + return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryFencePrefix} fence`, path) } const model = blockNodeModel(name) if (model === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path) const argumentKey = blockArgument(name) - const rest = new Map(attributes) + const spelled = attributes.get(marksAttribute) + const empty = readEmptyKeys(attributes, spellsEmpty(spelled) ? ['attrs', 'content', 'marks'] : ['attrs', 'content']) + if (empty.fault !== undefined) return faulted(empty.fault, path) + const { rest } = empty.value rest.delete(marksAttribute) const elsewhere: Elsewhere | undefined = argumentKey === undefined ? undefined : { key: argumentKey, slot: 'argument' } const attrs = readVocabulary(name, rest, model.attributes, elsewhere, path) @@ -38,10 +44,11 @@ export function readBlockDirectiveNode( if (argumentKey === undefined) return failure('unsupported-node-shape', `${name} takes no argument: this one spells one`, path) attrs.value[argumentKey] = argument } - const spelled = attributes.get(marksAttribute) - const marks: Result = spelled === undefined ? success(undefined) : readMarks(name, spelled, path) + const marks: Result = spelled === undefined || spellsEmpty(spelled) ? success(undefined) : readMarks(name, spelled, path) if (!marks.ok) return marks - return success({ contentModel: model.contentModel, node: namedNode(name, attrs.value, marks.value) }) + const node = namedNode(name, attrs.value, marks.value, empty.value.empty, path) + if (!node.ok) return node + return success({ contentModel: model.contentModel, emptyContent: empty.value.empty.has('content'), node: node.value }) } export function readInlineDirectiveNode( @@ -55,7 +62,9 @@ export function readInlineDirectiveNode( const slot = model.textAttribute if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path) const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' } - const attrs = readVocabulary(name, attributes, model.attributes, elsewhere, path) + const empty = readEmptyKeys(attributes, ['attrs', 'content', 'marks']) + if (empty.fault !== undefined) return faulted(empty.fault, path) + const attrs = readVocabulary(name, empty.value.rest, model.attributes, elsewhere, path) if (!attrs.ok) return attrs if (slot !== undefined && content !== undefined) { const text = slotText(content) @@ -66,14 +75,14 @@ export function readInlineDirectiveNode( if (spans !== undefined) return faulted(spans, path) attrs.value[slot] = text } - return success(namedNode(name, attrs.value, undefined)) + return namedNode(name, attrs.value, undefined, empty.value.empty, path) } // A name the other position spells names that spelling, never the code a later MINOR may fill (docs/decisions.md §Which code a cause takes). function inlineSpellingFault(name: string): ConvertFault | undefined { const mark = inlineMarkSpellingFault(name) if (mark !== undefined) return mark - if (inlineNodeModel(name) === undefined && name !== textDirectiveName) return undefined + if (inlineNodeModel(name) === undefined && name !== textDirectiveName && name !== textBreakName) return undefined return { code: 'unsupported-node-shape', message: `${name} takes the inline form, ${directivePrefix}${name}{…}, never the block form` } } @@ -100,7 +109,11 @@ function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath return success(marks) } -function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined): AdfNode { - const named = Object.keys(attrs).length === 0 ? { type } : { attrs, type } - return marks === undefined ? named : { ...named, marks: [...marks] } +function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined, empty: ReadonlySet, path: ConvertErrorPath): Result { + const held = Object.keys(attrs).length > 0 + if (held && empty.has('attrs')) return failure('unsupported-node-shape', `${type} spells attrs=empty beside an attribute it holds`, path) + const node: AdfNode = held || empty.has('attrs') ? { attrs, type } : { type } + if (empty.has('content')) node.content = [] + if (marks !== undefined || empty.has('marks')) node.marks = [...(marks ?? [])] + return success(node) } diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 192fd1e..7aeabe8 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -10,16 +10,16 @@ import { commonMarkLink, linkHref } from '../mark-spellings.ts' import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { highlightDelimiter, highlightFlanking } from '../plain-conventions.ts' +import { identicalMarks, nodeAttrs, nodeMarks } from '../../adf/document.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' -import { mergeAdjacentText, sameMarks } from '../../adf/editor-normal.ts' import { noSpans, readInlineDirective } from '../directive-syntax.ts' -import { nodeAttrs, nodeMarks } from '../../adf/document.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts' import { openingLinkTakesDirective } from '../emit/inline-line.ts' import { readCarriedInline } from '../opaque-carry.ts' import { readDirectiveMark } from './directive-marks.ts' import { readInlineDirectiveNode } from './directive-nodes.ts' import { readTextDirective } from '../text-directive.ts' +import { readsAsOne, textBreakName, textBreakSpelling } from '../text-break.ts' export type InlineContent = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: AdfNode[] } @@ -31,8 +31,10 @@ type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; lin type Pairing = EmphasisPairing +// A carry piece holds a node whose marks are its own: an opaque carry, or an inline node spelling marks=empty. type Piece = | Bracket + | { kind: 'break' } | { kind: 'carry'; node: AdfNode } | { alt: string; kind: 'image'; node: AdfNode } | { kind: 'nodes'; nodes: AdfNode[] } @@ -43,6 +45,8 @@ type Run = { canClose: boolean; canOpen: boolean; character: string; index: numb // `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`. type Scan = { + // Nodes a carry piece restored: they never merge, and only identity tells a carried textBreak from the leaf. + carried: Set container: LineContainer | undefined // Pieces below this have been walked for openers to deactivate: an image close folds the link-marked piece into alt text, leaving this the only record that the brackets around it are doomed. deactivatedBefore: number @@ -58,7 +62,7 @@ type Scan = { type SlotContent = { carry: boolean; nodes: AdfNode[] } -const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included' +const carriedInMark = 'no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own' const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' } const hreflessLink = 'the link mark spells its href: this one spells none' const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content' @@ -66,11 +70,11 @@ const linkInLink = 'no link wraps a link: the [content] this one marks already h const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or ' export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result { - return parseInline(source, definitions, path, container, noSpans, flavour === 'plain') + return parseInline(source, { carried: new Set(), container, definitions, highlights: flavour === 'plain', path, spans: noSpans }) } -function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans, highlights: boolean): Result { - const scan: Scan = { container, deactivatedBefore: 0, definitions, highlights, openingSpellableLink: false, path, pending: '', pieces: [], source, spans } +function parseInline(source: string, context: Pick): Result { + const scan: Scan = { ...context, deactivatedBefore: 0, openingSpellableLink: false, pending: '', pieces: [], source } let index = 0 while (index < source.length) { switch (source.charAt(index)) { @@ -103,7 +107,7 @@ function parseInline(source: string, definitions: LinkDefinitions, path: Convert index = readCharacter(scan, index) } } - flush(scan, container !== undefined) + flush(scan, scan.container !== undefined) return assemble(scan) } @@ -205,8 +209,9 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result< const carried = readCarriedInline(span) if (carried !== undefined) { if (carried.fault !== undefined) return faulted(carried.fault, scan.path) - return success({ kind: 'carry', node: carried.value }) + return success(carryPiece(scan, carried.value)) } + if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success({ kind: 'break' }) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.path) const text = readTextDirective(span) if (text?.fault !== undefined) return faulted(text.fault, scan.path) if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] }) @@ -216,7 +221,12 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result< if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark const node = readInlineDirectiveNode(span.name, span.attributes, slot.value?.nodes, scan.path) if (!node.ok) return node - return success({ kind: 'nodes', nodes: [node.value] }) + return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : carryPiece(scan, node.value)) +} + +function carryPiece(scan: Scan, node: AdfNode): Piece { + scan.carried.add(node) + return { kind: 'carry', node } } function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result { @@ -242,7 +252,7 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[ function slotContent(scan: Scan, span: DirectiveSpan): Result { if (span.content === undefined) return success(undefined) - const parsed = parseInline(span.content, scan.definitions, scan.path, undefined, span.spans, false) + const parsed = parseInline(span.content, { ...scan, container: undefined, highlights: false, spans: span.spans }) if (!parsed.ok) return parsed if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path) return success(parsed.value) @@ -262,7 +272,8 @@ function assemble(scan: Scan): Result { const only = scan.pieces[0] if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node }) if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path) - const nodes = resolveNodes(scan.pieces, scan.path, true) + const resolved = resolveNodes(scan.pieces, scan, true) + const nodes = resolved.ok && scan.container !== undefined ? partText(resolved.value, scan) : resolved if (!nodes.ok) return nodes if (scan.openingSpellableLink) { const takesDirective = openingLinkTakesDirective(nodes.value, scan.path) @@ -272,6 +283,27 @@ function assemble(scan: Scan): Result { return success({ carry: holdsCarry(scan.pieces), nodes: nodes.value }) } +// spec/flavour.md, Inline nodes: the leaf builds no node, so only the pair it parts spells it. +function partText(nodes: readonly AdfNode[], scan: Scan): Result { + const parted: AdfNode[] = [] + for (const [index, node] of nodes.entries()) { + if (!isTextBreak(node, scan.carried)) { + parted.push(node) + continue + } + const previous = nodes[index - 1] + const next = nodes[index + 1] + if (previous === undefined || next === undefined || scan.carried.has(previous) || scan.carried.has(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 success(parted) +} + +function isTextBreak(node: AdfNode, carried: ReadonlySet): boolean { + return node.type === textBreakName && !carried.has(node) +} + function holdsCarry(pieces: readonly Piece[]): boolean { return pieces.some((piece) => piece.kind === 'carry') } @@ -383,7 +415,7 @@ function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: } if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path) if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path) - const resolved = resolveNodes(inner, scan.path, true) + const resolved = resolveNodes(inner, scan, true) if (!resolved.ok) return resolved const nodes = resolved.value // An empty link text gives the mark no node to ride, so the brackets stay text. @@ -411,7 +443,7 @@ function truncatePieces(scan: Scan, to: number): void { function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result { if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path) - const resolved = imageAlt(inner, scan.path) + const resolved = imageAlt(inner, scan) if (!resolved.ok) return resolved const alt = resolved.value const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination } @@ -420,9 +452,10 @@ function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: return success(null) } -function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result { - const nodes = resolveNodes(inner, path, false) +function imageAlt(inner: readonly Piece[], scan: Scan): Result { + const nodes = resolveNodes(inner, scan, false) if (!nodes.ok) return nodes + if (nodes.value.some((node) => isTextBreak(node, scan.carried))) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: an image description holds plain text`, scan.path) return success(nodes.value.map(altText).join('')) } @@ -435,20 +468,35 @@ function altText(node: AdfNode): string { } // An image's alt text is plain, so `highlights` is off there and every `==` stays text. -function resolveNodes(pieces: readonly Piece[], path: ConvertErrorPath, highlights: boolean): Result { +function resolveNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean): Result { const nodes = pieces.map(pieceNodes) const runs = delimiterRuns(pieces) const pairings = matchEmphasis(runs) writeUnpaired(nodes, runs, pairings) - if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, path) + if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, scan.path) markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : []) - return success(mergeAdjacentText(nodes.flat())) + return success(mergeText(nodes.flat(), scan.carried)) +} + +function mergeText(nodes: readonly AdfNode[], carried: ReadonlySet): AdfNode[] { + const merged: AdfNode[] = [] + for (const node of nodes) { + const previous = merged[merged.length - 1] + if (previous !== undefined && !carried.has(previous) && !carried.has(node) && readsAsOne(previous, node)) { + merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` } + continue + } + merged.push(node) + } + return merged } // Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first. // A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it. function pieceNodes(piece: Piece): AdfNode[] { switch (piece.kind) { + case 'break': + return [{ type: textBreakName }] case 'carry': return [piece.node] case 'highlight': @@ -531,7 +579,7 @@ function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][]) let candidate = found[closer] while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) candidate = found[(closer += 1)] if (candidate === undefined) break - if (candidate.line !== opener.line || !sameMarks(opener.holder, candidate.holder)) continue + if (candidate.line !== opener.line || !identicalMarks(opener.holder, candidate.holder)) continue paired.push({ closer: candidate.index, opener: opener.index }) resume = candidate.position + highlightDelimiter.length } diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 283be1e..2773f6d 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -84,9 +84,21 @@ function table(...rows: AdfNode[]): AdfNode { return { content: rows, type: 'table' } } -test('builds an empty document from input holding no block', () => { - assert.deepEqual(markdownToAdf(''), { ok: true, value: { type: 'doc', version: 1 } }) - assert.deepEqual(content(markdownToAdf('\n \n\t\n')), []) +test('builds an empty document from input holding no block, and one holding no content key from the doc directive alone', () => { + assert.deepEqual(markdownToAdf(''), { ok: true, value: { content: [], type: 'doc', version: 1 } }) + assert.deepEqual(markdownToAdf('\n \n\t\n'), { ok: true, value: { content: [], type: 'doc', version: 1 } }) + assert.deepEqual(markdownToAdf('!adf:doc {content=none}\n'), { ok: true, value: { type: 'doc', version: 1 } }) + assert.deepEqual(markdownToAdf('\n!adf:doc {content=none}\n\n'), { ok: true, value: { type: 'doc', version: 1 } }) + 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="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' + 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') + assert.equal(content(markdownToAdf('a !adf:doc{content=none}\n')), 'unsupported-node-shape: doc takes the block form, !adf:doc, never the inline form') }) test('builds one paragraph from the lines a blank line does not part', () => { @@ -153,7 +165,7 @@ test('names the slot a codeBlock spells its language outside of', () => { const slot = 'unsupported-node-shape: codeBlock spells its language in the fence info string, or in the attribute where no info string carries it back' assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust wrap=true}\n```\nx\n```\n!adf:/codeBlock\n')), slot) assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust}\n```sql\nx\n```\n!adf:/codeBlock\n')), slot) - assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```carry\nx\n```\n!adf:/codeBlock\n')), slot) + assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```adf:x\nx\n```\n!adf:/codeBlock\n')), slot) assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```a\\b\nx\n```\n!adf:/codeBlock\n')), slot) }) @@ -343,18 +355,26 @@ test('names the position a directive name the other one spells belongs to', () = }) test('names the reserved carry name a block directive spells', () => { - const reserved = 'malformed-directive: the name carry is reserved for the opaque carry, whose block form is the carry fence' + const reserved = 'malformed-directive: the name carry is reserved for the opaque carry, whose block form is the adf: fence' assert.equal(content(markdownToAdf('!adf:carry\n')), reserved) assert.equal(content(markdownToAdf('!adf:carry\nx\n!adf:/carry\n')), reserved) assert.deepEqual(content(markdownToAdf('```adf\nx\n```\n')), [{ attrs: { language: 'adf' }, content: [text('x')], type: 'codeBlock' }]) + assert.deepEqual(content(markdownToAdf('```carry\nx\n```\n')), [{ attrs: { language: 'carry' }, content: [text('x')], type: 'codeBlock' }]) }) const carried = '!adf:carry{json="{\\"type\\":\\"placeholder\\"}"}' -test('reads the carry fence back to the node its JSON holds', () => { - assert.deepEqual(content(markdownToAdf('```carry\n{\n "attrs": {\n "url": "https://example.com/x"\n },\n "type": "blockCard"\n}\n```\n')), [ +test('reads the carry fence back to the node its info string names and its JSON holds', () => { + assert.deepEqual(content(markdownToAdf('```adf:blockCard\n{\n "attrs": {\n "url": "https://example.com/x"\n }\n}\n```\n')), [ { attrs: { url: 'https://example.com/x' }, type: 'blockCard' }, ]) + assert.deepEqual(content(markdownToAdf('```adf:\n{\n "type": "a`b"\n}\n```\n')), [{ type: 'a`b' }]) + assert.equal( + content(markdownToAdf('```adf:blockCard\n{\n "type": "blockCard"\n}\n```\n')), + "unsupported-node-shape: the adf:blockCard fence names its node's type: this JSON holds a type as well", + ) + assert.equal(content(markdownToAdf('```adf:\n{\n "type": "blockCard"\n}\n```\n')), 'unsupported-node-shape: the carry fence names a type its info string carries: spell it adf:blockCard') + assert.equal(content(markdownToAdf('```adf:\n{}\n```\n')), "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node") }) test('reads the inline carry back to the node its json attribute holds', () => { @@ -365,22 +385,22 @@ test('reads the inline carry back to the node its json attribute holds', () => { test('names the invalid JSON no opaque carry holds', () => { const invalid = 'malformed-directive: the opaque carry holds invalid JSON' - assert.equal(content(markdownToAdf('```carry\n{"type":\n```\n')), invalid) - assert.equal(content(markdownToAdf('```carry\n```\n')), invalid) + assert.equal(content(markdownToAdf('```adf:x\n{"attrs":\n```\n')), invalid) + assert.equal(content(markdownToAdf('```adf:x\n```\n')), invalid) assert.equal(content(markdownToAdf('!adf:carry{json="{"}\n')), invalid) assert.equal(content(markdownToAdf('!adf:carry{json=abc}\n')), invalid) }) test('names the canonical spelling a carried JSON reads alone', () => { const canonically = "unsupported-node-shape: the opaque carry spells its node's JSON canonically: " - assert.equal(content(markdownToAdf('```carry\n{"type":"blockCard"}\n```\n')), `${canonically}two-space indent, keys sorted`) + assert.equal(content(markdownToAdf('```adf:blockCard\n{"attrs":{}}\n```\n')), `${canonically}two-space indent, keys sorted`) assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted`) assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted`) }) test('names the node JSON an opaque carry restores alone', () => { const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node" - assert.equal(content(markdownToAdf('```carry\n[]\n```\n')), node) + assert.equal(content(markdownToAdf('```adf:x\n[]\n```\n')), node) assert.equal(content(markdownToAdf('!adf:carry{json=null}\n')), node) assert.equal(content(markdownToAdf('!adf:carry{json="{\\"kind\\":\\"x\\"}"}\n')), node) }) @@ -394,7 +414,7 @@ test('names the shape the inline carry reads alone', () => { test('holds a carried JSON value to the nesting its position leaves', () => { const nested = (levels: number): string => `${'['.repeat(levels)}${']'.repeat(levels)}` - const fence = (prefix: string, levels: number): string => `${prefix}\`\`\`carry\n${prefix}${nested(levels)}\n${prefix}\`\`\`\n` + const fence = (prefix: string, levels: number): string => `${prefix}\`\`\`adf:x\n${prefix}${nested(levels)}\n${prefix}\`\`\`\n` const deeper = (levels: number): string => `unsupported-nesting-depth: a carried node's JSON nests deeper than the ${levels} levels its position leaves` assert.equal(content(markdownToAdf(`!adf:carry{json="${nested(largestNesting + 2)}"}\n`)), deeper(largestNesting)) assert.equal( @@ -407,11 +427,11 @@ test('holds a carried JSON value to the nesting its position leaves', () => { test('names the number no JSON spelling carries in an opaque carry', () => { const named = 'unsupported-node-shape: the opaque carry holds a number JSON cannot spell' assert.equal(content(markdownToAdf('!adf:carry{json="{\\"attrs\\":{\\"width\\":1e999},\\"type\\":\\"blockCard\\"}"}\n')), named) - assert.equal(content(markdownToAdf('```carry\n1e999\n```\n')), named) + assert.equal(content(markdownToAdf('```adf:x\n1e999\n```\n')), named) }) test('names the mark spelling no opaque carry sits inside', () => { - const named = 'unsupported-node-shape: no mark spelling wraps an opaque carry: the carried node restores exactly, marks included' + const named = 'unsupported-node-shape: no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own' assert.equal(content(markdownToAdf(`_a ${carried} b_\n`)), named) assert.equal(content(markdownToAdf(`**${carried}**\n`)), named) assert.equal(content(markdownToAdf(`~~a ${carried}~~\n`)), named) @@ -454,7 +474,9 @@ test('names the marks key no marks array reads back from', () => { assert.equal(content(markdownToAdf('!adf:rule {marks="[1]"}\n')), named) assert.equal(content(markdownToAdf('!adf:rule {marks="{}"}\n')), named) assert.equal(content(markdownToAdf('!adf:rule {marks=x}\n')), named) - assert.equal(content(markdownToAdf('!adf:rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), named) + assert.equal(content(markdownToAdf('!adf:rule {marks="[{\\"type\\":\\"em\\",\\"attrs\\":{}}]"}\n')), named) + assert.deepEqual(content(markdownToAdf('!adf:rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), [{ marks: [{ attrs: {}, type: 'em' }], type: 'rule' }]) + assert.deepEqual(content(markdownToAdf('!adf:rule {marks=empty}\n')), [{ marks: [], type: 'rule' }]) }) test('names the attribute a node holds no reading for', () => { @@ -487,7 +509,8 @@ test('names the argument and the body a node takes no reading for', () => { assert.equal(content(markdownToAdf('!adf:rule x\n')), 'unsupported-node-shape: rule takes no argument: this one spells one') assert.equal(content(markdownToAdf('!adf:paragraph\nOne.\n\nTwo.\n!adf:/paragraph\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one') assert.equal(content(markdownToAdf('!adf:paragraph\n---\n!adf:/paragraph\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one') - assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\nx\n!adf:/codeBlock\n')), 'unsupported-node-shape: codeBlock takes one code block as its body: this body is not one') + assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\nx\n!adf:/codeBlock\n')), 'unsupported-node-shape: codeBlock takes code blocks as its body: this body holds another block') + assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n!adf:/codeBlock\n')), 'unsupported-node-shape: codeBlock takes code blocks as its body: this body holds none') assert.equal(content(markdownToAdf('!adf:paragraph\n![a](/u)\n!adf:/paragraph\n')), 'unmappable-image: no ADF node carries an image inside a paragraph') assert.equal(content(markdownToAdf('Part !adf:date[now]{timestamp=1}.\n')), 'unsupported-node-shape: date takes no content: this one holds some') }) @@ -545,7 +568,7 @@ test('names the line and the offset in the input a refusal sits at, the innermos assert.deepEqual(position(markdownToAdf('- Part.\n- a b\n')), { line: 2, offset: 8 }) assert.deepEqual(position(markdownToAdf('Part.\n\n!adf:panel info\nMore.\n')), { line: 3, offset: 7 }) assert.deepEqual(position(markdownToAdf('x\n\na b\n===\n')), { line: 3, offset: 3 }) - assert.deepEqual(position(markdownToAdf('x\n\n```carry\n{\n```\n')), { line: 3, offset: 3 }) + assert.deepEqual(position(markdownToAdf('x\n\n```adf:x\n{\n```\n')), { line: 3, offset: 3 }) assert.deepEqual(position(markdownToAdf('x\n\n| a |\n')), { line: 3, offset: 3 }) assert.deepEqual(position(markdownToAdf('a\nb c\n')), { line: 1, offset: 0 }) assert.deepEqual(position(markdownToAdf('Part.\r\n\r\n
\r\n')), { line: 3, offset: 9 }) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 8ed8a6a..55c42f0 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -5,14 +5,14 @@ import type { ConvertFault } from '../../result.ts' import type { Flavour } from '../plain-conventions.ts' import type { LineContainer } from '../line-container.ts' import type { LinkDefinitions } from './inline-content.ts' -import { carryName, readCarriedBlock } from '../opaque-carry.ts' +import { carryFencePrefix, readCarriedBlock } from '../opaque-carry.ts' import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts' +import { documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts' import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts' import { inlineLeaves } from '../emit/plain-inline.ts' import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain-conventions.ts' -import { listBreakName, listBreakSpelling } from '../block-directive.ts' import { mintTaskIds } from './task-ids.ts' import { nodeAttrs, nodeContent } from '../../adf/document.ts' import { parseBlocks } from './blocks.ts' @@ -39,10 +39,15 @@ export function plainMarkdownToAdf(markdown: string): Result { const parsed = parseBlocks(markdown) + const [only] = parsed.blocks + if (parsed.blocks.length === 1 && only?.kind === 'directive' && only.name === documentName) { + const fault = documentFault(only) + return fault === undefined ? success({ type: 'doc', version: 1 }) : positioned(faulted(fault, []), only.position) + } const reading: Reading = { carried: new Set(), definitions: parsed.definitions, flavour, inExpand: false, memo: new Map() } const content = positioned(readBlocks(parsed.blocks, reading, [], 0), documentStart) if (!content.ok) return content - const document: AdfDocument = content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 } + const document: AdfDocument = { content: content.value, type: 'doc', version: 1 } if (flavour === 'plain') mintTaskIds(document, markdown, reading.carried) return success(document) } @@ -52,6 +57,10 @@ function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErr const content: AdfNode[] = [] 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`) + return positioned(faulted(fault, nodePath), block.position) + } if (block.kind === 'directive' && block.name === listBreakName) { const fault = listBreakFault(block, blocks[index - 1], blocks[index + 1]) if (fault !== undefined) return positioned(faulted(fault, nodePath), block.position) @@ -73,6 +82,12 @@ function listBreakFault(block: DirectiveBlock, previous: Block | undefined, next return previous.kind === next?.kind ? undefined : partsFault() } +function documentFault(block: DirectiveBlock): ConvertFault | undefined { + const content = block.attributes.get('content') + if (block.argument === undefined && block.attributes.size === 1 && content?.spelling === 'none') return undefined + return unsupportedNodeShape(`${documentName} spells the one form ${documentSpelling}: this one spells another`) +} + function partsFault(): ConvertFault { return unsupportedNodeShape(`${listBreakName} parts two adjacent lists of one type: this one parts something else`) } @@ -214,24 +229,33 @@ function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErr } function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, reading: Reading, path: ConvertErrorPath, depth: number): Result { - const { contentModel, node } = read + const { contentModel, emptyContent, node } = read if (blocks === undefined) return success(node) + if (emptyContent) 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 (contentModel === 'code') return codeDirectiveNode(node, blocks, path) if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path) return containerNode(node, blocks, reading, path, depth) } +// spec/flavour.md, The CommonMark blocks: one fence per text node, every fence carrying the one language. function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result { - const only = blocks.length === 1 ? blocks[0] : undefined - if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one code block as its body: this body is not one`, path) + const fences: Extract[] = [] + for (const block of blocks) { + if (block.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes code blocks as its body: this body holds another block`, path) + fences.push(block) + } + 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) const attribute = nodeAttrs(node)['language'] - const fromFence = only.language !== '' - const slot = languageSlot(fromFence ? only.language : attribute) + const fromFence = first.language !== '' + const slot = languageSlot(fromFence ? first.language : attribute) if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) { return failure('unsupported-node-shape', `${node.type} spells its language in the fence info string, or in the attribute where no info string carries it back`, path) } - const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: only.language } } : node - return success(withContent(spelled, only.text === '' ? [] : [{ text: only.text, type: 'text' }])) + const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: first.language } } : node + return success(withContent(spelled, first.text === '' ? [] : fences.map((fence): AdfNode => ({ text: fence.text, type: 'text' })))) } function tableNode(rows: readonly string[][], reading: Reading, path: ConvertErrorPath): Result { @@ -278,8 +302,8 @@ function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, pa } function codeBlockNode(language: string, text: string, reading: Reading, path: ConvertErrorPath, depth: number): Result { - if (language === carryName) { - const carried = readCarriedBlock(text, depth) + if (language.startsWith(carryFencePrefix)) { + const carried = readCarriedBlock(language.slice(carryFencePrefix.length), text, depth) if (carried.fault !== undefined) return faulted(carried.fault, path) reading.carried.add(carried.value) return success(carried.value) diff --git a/src/markdown/text-break.ts b/src/markdown/text-break.ts new file mode 100644 index 0000000..de1174c --- /dev/null +++ b/src/markdown/text-break.ts @@ -0,0 +1,16 @@ +import type { AdfNode } from '../adf/document.ts' +import { identicalMarks } from '../adf/document.ts' +import { spellInlineLeafDirective } from './directive-syntax.ts' + +export const textBreakName = 'textBreak' + +export const textBreakSpelling = spellInlineLeafDirective(textBreakName, '') + +// Whether CommonMark reads the pair back as one text node, where neither rides the carry (spec/flavour.md, Inline nodes). +export function readsAsOne(previous: AdfNode, node: AdfNode): boolean { + return spelledAsText(previous) && spelledAsText(node) && identicalMarks(previous, node) +} + +function spelledAsText(node: AdfNode): boolean { + return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0 +}