From 7035e42d1dc12689c5050b9c48b47f3b0ce9004f Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 27 Aug 2026 10:38:34 +0200 Subject: [PATCH] Carry what no section or mark spelling writes, and drop the mark refusal --- AGENTS.md | 3 +- .../combinations/carry-attributes.json | 60 ++++++ .../combinations/carry-attributes.md | 41 ++++ .../round-trip/combinations/carry-marks.json | 94 +++++++++ corpus/round-trip/combinations/carry-marks.md | 9 + .../combinations}/mark-inside-word.json | 0 .../combinations/mark-inside-word.md | 1 + corpus/unspellable/mark-inside-word.error | 1 - spec/flavour.md | 7 +- src/adf-to-markdown.test.ts | 144 +++++++++---- src/adf-to-markdown.ts | 8 +- src/block-directives.ts | 29 ++- src/directive-attributes.ts | 18 +- src/inline-directives.ts | 23 +-- src/markdown-escaping.ts | 24 ++- src/markdown-inline.ts | 193 ++++++++++-------- src/result.ts | 1 - todo.md | 2 +- 18 files changed, 459 insertions(+), 199 deletions(-) create mode 100644 corpus/round-trip/combinations/carry-attributes.json create mode 100644 corpus/round-trip/combinations/carry-attributes.md create mode 100644 corpus/round-trip/combinations/carry-marks.json create mode 100644 corpus/round-trip/combinations/carry-marks.md rename corpus/{unspellable => round-trip/combinations}/mark-inside-word.json (100%) create mode 100644 corpus/round-trip/combinations/mark-inside-word.md delete mode 100644 corpus/unspellable/mark-inside-word.error diff --git a/AGENTS.md b/AGENTS.md index 464f49b..d0eae7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,8 @@ closed list a consumer may switch exhaustively, the message free text, the path from the document root. Adding, removing or renaming a code is breaking, so a milestone meeting a new failure cause reuses a code where one fits; the list is complete at `0.1.0`. A code names the cause; where one cause recurs across node types, one code covers them all and `path` and `message` -say which. +say which. A cause the carry answers gets no code: no mark refuses, since a mark no spelling writes +rides the carry with its node. ## 9. Release automation diff --git a/corpus/round-trip/combinations/carry-attributes.json b/corpus/round-trip/combinations/carry-attributes.json new file mode 100644 index 0000000..8404b7d --- /dev/null +++ b/corpus/round-trip/combinations/carry-attributes.json @@ -0,0 +1,60 @@ +{ + "content": [ + { + "attrs": { + "rounded": true + }, + "content": [ + { + "content": [ + { + "text": "Check the collation.", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "panel" + }, + { + "attrs": { + "panelType": "extra info" + }, + "content": [ + { + "content": [ + { + "text": "Two words fit no arg slot.", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "panel" + }, + { + "content": [ + { + "text": "See ", + "type": "text" + }, + { + "attrs": { + "localId": 5, + "text": "In review" + }, + "type": "status" + }, + { + "text": " now.", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/carry-attributes.md b/corpus/round-trip/combinations/carry-attributes.md new file mode 100644 index 0000000..0f5a99b --- /dev/null +++ b/corpus/round-trip/combinations/carry-attributes.md @@ -0,0 +1,41 @@ +```adf +{ + "attrs": { + "rounded": true + }, + "content": [ + { + "content": [ + { + "text": "Check the collation.", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "panel" +} +``` + +```adf +{ + "attrs": { + "panelType": "extra info" + }, + "content": [ + { + "content": [ + { + "text": "Two words fit no arg slot.", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "panel" +} +``` + +See :adf{json="{\"attrs\":{\"localId\":5,\"text\":\"In review\"},\"type\":\"status\"}"} now. diff --git a/corpus/round-trip/combinations/carry-marks.json b/corpus/round-trip/combinations/carry-marks.json new file mode 100644 index 0000000..7ebbf57 --- /dev/null +++ b/corpus/round-trip/combinations/carry-marks.json @@ -0,0 +1,94 @@ +{ + "content": [ + { + "content": [ + { + "text": "Reviewed ", + "type": "text" + }, + { + "marks": [ + { + "attrs": { + "annotationType": "inlineComment", + "id": "0198f3a2-7c41-7f2e-9b3a-4d8e2c1a6b90" + }, + "type": "annotation" + } + ], + "text": "audited", + "type": "text" + }, + { + "text": " twice.", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "attrs": { + "colour": "red" + }, + "type": "em" + } + ], + "text": "urgent", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "code" + }, + { + "type": "strong" + } + ], + "text": "flag", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "code" + } + ], + "text": "a\nb", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "link" + } + ], + "text": "Release notes", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/carry-marks.md b/corpus/round-trip/combinations/carry-marks.md new file mode 100644 index 0000000..53accb3 --- /dev/null +++ b/corpus/round-trip/combinations/carry-marks.md @@ -0,0 +1,9 @@ +Reviewed :adf{json="{\"marks\":[{\"attrs\":{\"annotationType\":\"inlineComment\",\"id\":\"0198f3a2-7c41-7f2e-9b3a-4d8e2c1a6b90\"},\"type\":\"annotation\"}],\"text\":\"audited\",\"type\":\"text\"}"} twice. + +:adf{json="{\"marks\":[{\"attrs\":{\"colour\":\"red\"},\"type\":\"em\"}],\"text\":\"urgent\",\"type\":\"text\"}"} + +:adf{json="{\"marks\":[{\"type\":\"code\"},{\"type\":\"strong\"}],\"text\":\"flag\",\"type\":\"text\"}"} + +:adf{json="{\"marks\":[{\"type\":\"code\"}],\"text\":\"a\\nb\",\"type\":\"text\"}"} + +:adf{json="{\"marks\":[{\"type\":\"link\"}],\"text\":\"Release notes\",\"type\":\"text\"}"} diff --git a/corpus/unspellable/mark-inside-word.json b/corpus/round-trip/combinations/mark-inside-word.json similarity index 100% rename from corpus/unspellable/mark-inside-word.json rename to corpus/round-trip/combinations/mark-inside-word.json diff --git a/corpus/round-trip/combinations/mark-inside-word.md b/corpus/round-trip/combinations/mark-inside-word.md new file mode 100644 index 0000000..6d15c53 --- /dev/null +++ b/corpus/round-trip/combinations/mark-inside-word.md @@ -0,0 +1 @@ +un:adf{json="{\"marks\":[{\"type\":\"strong\"}],\"text\":\"-real\",\"type\":\"text\"}"}istic diff --git a/corpus/unspellable/mark-inside-word.error b/corpus/unspellable/mark-inside-word.error deleted file mode 100644 index 09d223e..0000000 --- a/corpus/unspellable/mark-inside-word.error +++ /dev/null @@ -1 +0,0 @@ -unspellable-mark diff --git a/spec/flavour.md b/spec/flavour.md index 1dd9f6f..dca03a0 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -387,9 +387,10 @@ inline nodes carrying an identical mark, attributes included, at that depth. A r node the emitter carries, so no emitted carry sits inside a mark spelling. An inline node whose marks no nesting spells — a mark type not listed here, an attribute no -spelling holds, an order putting a code span outside another mark, `code` on text holding a -newline, or a spelling CommonMark's flanking rules cannot open or close where the run sits -(`un**-real**istic`) — rides the inline carry whole. An opaque carry inside a mark spelling is a +spelling holds or one a spelling needs and the mark lacks, an order putting a code span outside +another mark, `code` over anything but a text node or over text holding a newline, or a spelling +CommonMark's flanking rules cannot open or close where the run sits (`un**-real**istic`) — rides +the inline carry whole. An opaque carry inside a mark spelling is a named error in input: the carry restores its node exactly, marks included (AGENTS.md §3). ``` diff --git a/src/adf-to-markdown.test.ts b/src/adf-to-markdown.test.ts index d41b5e4..7a5bff1 100644 --- a/src/adf-to-markdown.test.ts +++ b/src/adf-to-markdown.test.ts @@ -85,26 +85,36 @@ test('refuses a link title CommonMark cannot spell', () => { assert.equal(code(adfToMarkdown(titled('a\nb'))), 'unspellable-link-title') }) -test('refuses a link attribute no markdown spelling holds', () => { +test('carries a link mark the link spelling cannot write', () => { + const carried = (mark: AdfMark): string => markdown(adfToMarkdown(document(paragraph({ marks: [mark], text: 't', type: 'text' })))) assert.equal( - code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: 'https://example.com/', id: 'x' }, type: 'link' }], text: 't', type: 'text' })))), - 'unspellable-mark', + carried({ attrs: { href: 'x', id: 'y' }, type: 'link' }), + ':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":\\"x\\",\\"id\\":\\"y\\"},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n', ) - assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: 4 }, type: 'link' }], text: 't', type: 'text' })))), 'unspellable-mark') - assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'link' }], text: 't', type: 'text' })))), 'unsupported-node-shape') + assert.equal(carried({ attrs: { href: 4 }, type: 'link' }), ':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":4},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n') + assert.equal(carried({ type: 'link' }), ':adf{json="{\\"marks\\":[{\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n') }) -test('refuses a mark the canonical spellings cannot nest', () => { - assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'annotation' }], text: 'x', type: 'text' })))), 'unspellable-mark') - assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }, { type: 'strong' }], text: 'x', type: 'text' })))), 'unspellable-mark') - assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { colour: 'red' }, type: 'em' }], text: 'x', type: 'text' })))), 'unspellable-mark') - assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { localId: 'x' }, type: 'code' }], text: 'x', type: 'text' })))), 'unspellable-mark') +test('carries a mark the canonical spellings cannot nest', () => { + const carried = (...marks: AdfMark[]): string => markdown(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))) + assert.equal(carried({ type: 'annotation' }), ':adf{json="{\\"marks\\":[{\\"type\\":\\"annotation\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n') + assert.equal( + carried({ type: 'code' }, { type: 'strong' }), + ':adf{json="{\\"marks\\":[{\\"type\\":\\"code\\"},{\\"type\\":\\"strong\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n', + ) + assert.equal( + carried({ attrs: { colour: 'red' }, type: 'em' }), + ':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"colour\\":\\"red\\"},\\"type\\":\\"em\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n', + ) + assert.equal( + carried({ attrs: { localId: 'x' }, type: 'code' }), + ':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"localId\\":\\"x\\"},\\"type\\":\\"code\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n', + ) }) test('refuses whitespace CommonMark cannot hold', () => { assert.equal(code(adfToMarkdown(document(paragraph({ text: '\fa', type: 'text' })))), 'unspellable-whitespace') assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\rb', type: 'text' })))), 'unspellable-whitespace') - assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'em' }], text: 'x ', type: 'text' }, { text: 'y', type: 'text' })))), 'unspellable-mark') }) test('refuses a line whose start block parsing would claim', () => { @@ -164,8 +174,11 @@ test('refuses an ordered list no marker spells', () => { assert.equal(code(adfToMarkdown(list(999999999, 2))), 'unspellable-list-marker') }) -test('refuses a code span over anything but one text node', () => { - assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], type: 'hardBreak' })))), 'unspellable-mark') +test('carries a code mark over anything but text', () => { + assert.equal( + markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], type: 'hardBreak' })))), + ':adf{json="{\\"marks\\":[{\\"type\\":\\"code\\"}],\\"type\\":\\"hardBreak\\"}"}\n', + ) }) test('refuses a heading level outside the ATX range', () => { @@ -207,18 +220,30 @@ test('wraps adjacent nodes carrying one mark once, and a differing mark twice', assert.equal(emitted(marked('a', link('http://x')), marked('b', link('http://y'))), '[a](http://x)[b](http://y)\n') }) -test('refuses a mark spelling that cannot open or close where it sits', () => { +test('carries a mark spelling that cannot open or close where it sits', () => { const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' }) const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content)))) const strong: AdfMark = { type: 'strong' } - assert.equal(code(adfToMarkdown(document(paragraph({ text: 'un', type: 'text' }, marked('-real', strong), { text: 'istic', type: 'text' })))), 'unspellable-mark') - assert.equal(code(adfToMarkdown(document(paragraph(marked('C++', { type: 'em' }), { text: 'ish', type: 'text' })))), 'unspellable-mark') - assert.equal(code(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, marked('.a', strong))))), 'unspellable-mark') + assert.equal( + emitted({ text: 'un', type: 'text' }, marked('-real', strong), { text: 'istic', type: 'text' }), + 'un:adf{json="{\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"text\\":\\"-real\\",\\"type\\":\\"text\\"}"}istic\n', + ) + assert.equal( + emitted(marked('C++', { type: 'em' }), { text: 'ish', type: 'text' }), + ':adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"C++\\",\\"type\\":\\"text\\"}"}ish\n', + ) + assert.equal( + emitted({ text: 'x', type: 'text' }, marked('.a', strong)), + 'x:adf{json="{\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"text\\":\\".a\\",\\"type\\":\\"text\\"}"}\n', + ) assert.equal(emitted({ text: 'un ', type: 'text' }, marked('-real', strong), { text: ' istic', type: 'text' }), 'un **-real** istic\n') assert.equal(emitted(marked('a.', strong)), '**a.**\n') assert.equal(emitted({ text: 'x', type: 'text' }, marked('a', strong), { text: 'y', type: 'text' }), 'x**a**y\n') const em: AdfMark = { type: 'em' } - assert.equal(code(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, marked('a.', em), marked('b', strong))))), 'unspellable-mark') + assert.equal( + emitted({ text: 'x', type: 'text' }, marked('a.', em), marked('b', strong)), + 'x:adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"a.\\",\\"type\\":\\"text\\"}"}**b**\n', + ) assert.equal(emitted({ text: 'x', type: 'text' }, marked('ab', em, strong), { text: 'y', type: 'text' }), 'x***ab***y\n') }) @@ -252,7 +277,10 @@ test('escapes a literal delimiter that would merge with an emitted one', () => { assert.equal(emitted(marked('_a', { type: 'em' })), '_\\_a_\n') assert.equal(emitted(marked('a*', { type: 'strong' })), '**a\\***\n') assert.equal(emitted(marked('~a', { type: 'strike' })), '~~\\~a~~\n') - assert.equal(code(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, marked('~a', { type: 'strike' }))))), 'unspellable-mark') + assert.equal( + emitted({ text: 'x', type: 'text' }, marked('~a', { type: 'strike' })), + 'x:adf{json="{\\"marks\\":[{\\"type\\":\\"strike\\"}],\\"text\\":\\"~a\\",\\"type\\":\\"text\\"}"}\n', + ) assert.equal(emitted({ text: '`', type: 'text' }, marked('x', { type: 'code' })), '\\``x`\n') assert.equal(emitted(marked('x', { type: 'code' }), { text: '`', type: 'text' }), '`x`\\`\n') assert.equal(emitted({ text: '`a', type: 'text' }, marked('b', { type: 'code' })), '\\`a`b`\n') @@ -291,12 +319,19 @@ test('refuses the characters CommonMark rewrites', () => { test('refuses a text node carrying no text at all', () => { assert.equal(code(adfToMarkdown(document(paragraph({ text: '', type: 'text' })))), 'unsupported-node-shape') + assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))), 'unsupported-node-shape') }) -test('refuses a mark run whose edge holds whitespace CommonMark flanking counts', () => { +test('carries a mark run whose edge holds whitespace CommonMark flanking counts', () => { const em = { type: 'em' } - assert.equal(code(adfToMarkdown(document(paragraph({ marks: [em], text: 'a', type: 'text' }, { marks: [em], type: 'hardBreak' }, { text: 'b', type: 'text' })))), 'unspellable-whitespace') - assert.equal(code(adfToMarkdown(document(paragraph({ marks: [em], text: '\u00a0a', type: 'text' })))), 'unspellable-whitespace') + assert.equal( + markdown(adfToMarkdown(document(paragraph({ marks: [em], text: 'a', type: 'text' }, { marks: [em], type: 'hardBreak' }, { text: 'b', type: 'text' })))), + ':adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"a\\",\\"type\\":\\"text\\"}"}:adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"type\\":\\"hardBreak\\"}"}b\n', + ) + assert.equal( + markdown(adfToMarkdown(document(paragraph({ marks: [em], text: '\u00a0a', type: 'text' })))), + ':adf{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\"\u00a0a\\",\\"type\\":\\"text\\"}"}\n', + ) }) test('pads a code span whose edges CommonMark would strip', () => { @@ -329,17 +364,32 @@ test('spells a block directive as its node type, arg and attributes', () => { assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '::syncBlock {localId=a}\n') }) -test('refuses a directive attribute no section spells', () => { - assert.equal(code(adfToMarkdown(document({ attrs: { rounded: true }, type: 'panel' }))), 'unspelled-node-attribute') - assert.equal(code(adfToMarkdown(document({ attrs: { toString: 'x' }, type: 'panel' }))), 'unspelled-node-attribute') - assert.equal(code(adfToMarkdown(document({ attrs: { localId: 4 }, type: 'panel' }))), 'unsupported-node-shape') - assert.equal(code(adfToMarkdown(document({ attrs: { width: '50' }, type: 'layoutColumn' }))), 'unsupported-node-shape') - assert.equal(code(adfToMarkdown(document({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }))), 'unsupported-node-shape') +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' }), '```adf\n{\n "attrs": {\n "rounded": true\n },\n "type": "panel"\n}\n```\n') + assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```adf\n{\n "attrs": {\n "toString": "x"\n },\n "type": "panel"\n}\n```\n') + assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```adf\n{\n "attrs": {\n "localId": 4\n },\n "type": "panel"\n}\n```\n') + assert.equal( + carried({ attrs: { width: '50' }, type: 'layoutColumn' }), + '```adf\n{\n "attrs": {\n "width": "50"\n },\n "type": "layoutColumn"\n}\n```\n', + ) + assert.equal( + carried({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }), + '```adf\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n },\n "type": "table"\n}\n```\n', + ) + assert.equal( + carried({ content: [{ attrs: { alt: 4 }, type: 'media' }], type: 'mediaGroup' }), + ':::mediaGroup\n```adf\n{\n "attrs": {\n "alt": 4\n },\n "type": "media"\n}\n```\n:::\n', + ) }) -test('refuses an arg slot value no bare token spells', () => { - assert.equal(code(adfToMarkdown(document({ attrs: { panelType: 'extra info' }, type: 'panel' }))), 'unspelled-node-attribute') - assert.equal(code(adfToMarkdown(document({ attrs: { state: 2 }, type: 'taskItem' }))), 'unspelled-node-attribute') +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' }), + '```adf\n{\n "attrs": {\n "panelType": "extra info"\n },\n "type": "panel"\n}\n```\n', + ) + assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```adf\n{\n "attrs": {\n "state": 2\n },\n "type": "taskItem"\n}\n```\n') }) test('carries a block node mark in the reserved attribute', () => { @@ -376,8 +426,8 @@ test('spells the image form for exactly the centered external media shape', () = assert.ok(fallback({ alt: 'a\u0000b', type: 'external', url })) assert.ok(fallback({ type: 'external', url: 'https://example.com/a b>c' })) assert.ok(fallback({ alt: ' moon ', type: 'external', url })) - assert.equal(code(adfToMarkdown(single({ alt: 4, type: 'external', url }))), 'unsupported-node-shape') - assert.equal(code(adfToMarkdown(single({ type: 'external', url: 4 }))), 'unsupported-node-shape') + assert.ok(fallback({ alt: 4, type: 'external', url })) + assert.ok(fallback({ type: 'external', url: 4 })) assert.equal(code(adfToMarkdown(single({ type: 'external', url }, paragraph()))), 'unsupported-node-shape') }) @@ -435,12 +485,13 @@ test('spells an inline node as a directive with its content slot and attributes' assert.equal(emitted({ attrs: { url: 'a`b&c { - const refused = (node: AdfNode): string => code(adfToMarkdown(document(paragraph(node)))) - assert.equal(refused({ attrs: { rounded: true }, type: 'status' }), 'unspelled-node-attribute') - assert.equal(refused({ attrs: { toString: 'x' }, type: 'status' }), 'unspelled-node-attribute') - assert.equal(refused({ attrs: { color: 4 }, type: 'status' }), 'unsupported-node-shape') - assert.equal(refused({ attrs: { width: '2' }, type: 'mediaInline' }), 'unsupported-node-shape') +test('carries an inline node attribute no section spells', () => { + const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(paragraph(node)))) + assert.equal(carried({ attrs: { rounded: true }, type: 'status' }), ':adf{json="{\\"attrs\\":{\\"rounded\\":true},\\"type\\":\\"status\\"}"}\n') + assert.equal(carried({ attrs: { toString: 'x' }, type: 'status' }), ':adf{json="{\\"attrs\\":{\\"toString\\":\\"x\\"},\\"type\\":\\"status\\"}"}\n') + assert.equal(carried({ attrs: { color: 4 }, type: 'status' }), ':adf{json="{\\"attrs\\":{\\"color\\":4},\\"type\\":\\"status\\"}"}\n') + assert.equal(carried({ attrs: { width: '2' }, type: 'mediaInline' }), ':adf{json="{\\"attrs\\":{\\"width\\":\\"2\\"},\\"type\\":\\"mediaInline\\"}"}\n') + assert.equal(carried({ attrs: { text: 4 }, type: 'status' }), ':adf{json="{\\"attrs\\":{\\"text\\":4},\\"type\\":\\"status\\"}"}\n') }) test('refuses the content and slot an inline directive has no room for', () => { @@ -449,7 +500,6 @@ test('refuses the content and slot an inline directive has no room for', () => { assert.equal(refused({ text: 'x', type: 'status' }), 'unsupported-node-shape') assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'hardBreak' }), 'unsupported-node-shape') assert.equal(refused({ text: 'x', type: 'hardBreak' }), 'unsupported-node-shape') - assert.equal(refused({ attrs: { text: 4 }, type: 'status' }), 'unsupported-node-shape') assert.equal(refused({ attrs: { text: 'a\nb' }, type: 'status' }), 'unspellable-whitespace') assert.equal(refused({ attrs: { text: 'a\u0000b' }, type: 'status' }), 'unspellable-character') }) @@ -477,10 +527,16 @@ test('spells the directive marks around the longest run they cover', () => { ) }) -test('refuses a mark directive attribute no spelling holds', () => { - const refused = (mark: AdfMark): string => code(adfToMarkdown(document(paragraph({ marks: [mark], text: 'x', type: 'text' })))) - assert.equal(refused({ attrs: { width: 2 }, type: 'border' }), 'unspellable-mark') - assert.equal(refused({ attrs: { size: '2' }, type: 'border' }), 'unspellable-mark') +test('carries a mark directive attribute no spelling holds', () => { + const carried = (mark: AdfMark): string => markdown(adfToMarkdown(document(paragraph({ marks: [mark], text: 'x', type: 'text' })))) + assert.equal( + carried({ attrs: { width: 2 }, type: 'border' }), + ':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"width\\":2},\\"type\\":\\"border\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n', + ) + assert.equal( + carried({ attrs: { size: '2' }, type: 'border' }), + ':adf{json="{\\"marks\\":[{\\"attrs\\":{\\"size\\":\\"2\\"},\\"type\\":\\"border\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n', + ) }) test('carries whitespace CommonMark strips in the reserved text directive', () => { diff --git a/src/adf-to-markdown.ts b/src/adf-to-markdown.ts index b3ed67f..71add5e 100644 --- a/src/adf-to-markdown.ts +++ b/src/adf-to-markdown.ts @@ -110,19 +110,19 @@ function commonMarkContainer(body: Result): Result { function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result { if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path) - const header = spellDirectiveHeader(node, directive, path) - if (!header.ok) return header + const header = spellDirectiveHeader(node, directive) + if (header === undefined) return commonMarkLine(carriedBlock(node, path)) const content = node.content ?? [] if (directive.body === 'none') { if (content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path) - return success({ fenceColons: 2, spelling: 'directive', text: `::${header.value}` }) + return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` }) } const body = directive.body === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1) if (!body.ok) return body const fenceColons = Math.max(3, body.value.fenceColons + 1) const fence = ':'.repeat(fenceColons) const lines = body.value.text === '' ? '' : `${body.value.text}\n` - return success({ fenceColons, spelling: 'directive', text: `${fence}${header.value}\n${lines}${fence}` }) + return success({ fenceColons, spelling: 'directive', text: `${fence}${header}\n${lines}${fence}` }) } function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Result { diff --git a/src/block-directives.ts b/src/block-directives.ts index 6064a19..934ebbd 100644 --- a/src/block-directives.ts +++ b/src/block-directives.ts @@ -1,7 +1,6 @@ import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts' import type { JsonValue } from './json-value.ts' -import { failure, success, type ConvertErrorPath, type Result } from './result.ts' -import { attributeFailure, isBareToken, spellAttributes, spellJsonAttribute, vocabularyPairs } from './directive-attributes.ts' +import { isBareToken, spellAttributes, spellJsonAttribute, vocabularyPairs } from './directive-attributes.ts' export type BlockDirective = { argument?: string @@ -80,24 +79,22 @@ export function blockDirective(type: string): BlockDirective | undefined { return Object.hasOwn(blockDirectives, type) ? blockDirectives[type] : undefined } -export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result { - const argument = spellArgument(node, directive, path) - if (!argument.ok) return argument - const spelled = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.argument) - if (spelled.fault !== undefined) return attributeFailure(node.type, spelled.fault, path) +export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective): string | undefined { + const argument = spellArgument(node, directive) + if (argument === undefined) return undefined + const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.argument) + if (pairs === undefined) return undefined const marks = node.marks ?? [] - if (marks.length > 0) spelled.pairs.push(['marks', spellJsonAttribute(markValues(marks))]) - const attributes = spellAttributes(spelled.pairs) - return success(`${node.type}${argument.value}${attributes === '' ? '' : ` ${attributes}`}`) + if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))]) + const attributes = spellAttributes(pairs) + return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}` } -function spellArgument(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result { +function spellArgument(node: AdfNode, directive: BlockDirective): string | undefined { const value = directive.argument === undefined ? undefined : node.attrs?.[directive.argument] - if (value === undefined) return success('') - if (typeof value !== 'string' || !isBareToken(value)) { - return failure('unspelled-node-attribute', `the ${node.type} attribute ${directive.argument} holds no bare token the arg slot spells`, path) - } - return success(` ${value}`) + if (value === undefined) return '' + if (typeof value !== 'string' || !isBareToken(value)) return undefined + return ` ${value}` } function markValues(marks: readonly AdfMark[]): JsonValue { diff --git a/src/directive-attributes.ts b/src/directive-attributes.ts index 88992c2..53a8988 100644 --- a/src/directive-attributes.ts +++ b/src/directive-attributes.ts @@ -1,12 +1,7 @@ import type { AdfAttributes, AttributeKind, AttributeVocabulary } from './adf-document.ts' import type { JsonValue } from './json-value.ts' -import { failure, type ConvertErrorPath, type Result } from './result.ts' import { serializeCanonicalJson } from './canonical-json.ts' -export type AttributeFault = { key: string; kind: AttributeKind | undefined } - -export type SpelledPairs = { fault: AttributeFault; pairs?: undefined } | { fault?: undefined; pairs: [string, string][] } - const bareToken = /^[A-Za-z0-9_-]+$/ // spec/flavour.md, Attributes. @@ -16,22 +11,17 @@ export function isBareToken(text: string): boolean { return bareToken.test(text) } -export function attributeFailure(type: string, fault: AttributeFault, path: ConvertErrorPath): Result { - if (fault.kind === undefined) return failure('unspelled-node-attribute', `the ${type} attribute ${fault.key} has no canonical markdown spelling`, path) - return failure('unsupported-node-shape', `the ${type} attribute ${fault.key} holds no ${fault.kind}`, path) -} - -export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, slot: string | undefined): SpelledPairs { +export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, slot: string | undefined): [string, string][] | undefined { const pairs: [string, string][] = [] for (const [key, value] of Object.entries(attrs)) { if (key === slot) continue const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined - if (kind === undefined) return { fault: { key, kind: undefined } } + if (kind === undefined) return undefined const spelled = spellAttributeValue(value, kind) - if (spelled === undefined) return { fault: { key, kind } } + if (spelled === undefined) return undefined pairs.push([key, spelled]) } - return { pairs } + return pairs } export function spellAttributes(pairs: readonly (readonly [string, string])[]): string { diff --git a/src/inline-directives.ts b/src/inline-directives.ts index 0af1aa6..d18c258 100644 --- a/src/inline-directives.ts +++ b/src/inline-directives.ts @@ -1,7 +1,5 @@ import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts' -import type { AttributeFault } from './directive-attributes.ts' -import { failure, success, type ConvertErrorPath, type Result } from './result.ts' -import { attributeFailure, spellAttributes, vocabularyPairs } from './directive-attributes.ts' +import { spellAttributes, vocabularyPairs } from './directive-attributes.ts' export type InlineDirective = { attributes: AttributeVocabulary @@ -53,19 +51,12 @@ export function markSpelling(type: string): MarkSpelling | undefined { return Object.hasOwn(markSpellings, type) ? markSpellings[type] : undefined } -export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result { - const spelled = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot) - if (spelled.fault !== undefined) return attributeFailure(node.type, spelled.fault, path) - return success(spellAttributes(spelled.pairs)) +export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined { + const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot) + return pairs === undefined ? undefined : spellAttributes(pairs) } -export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary, path: ConvertErrorPath): Result { - const spelled = vocabularyPairs(mark.attrs ?? {}, vocabulary, undefined) - if (spelled.fault !== undefined) return markFailure(mark.type, spelled.fault, path) - return success(spellAttributes(spelled.pairs)) -} - -function markFailure(type: string, fault: AttributeFault, path: ConvertErrorPath): Result { - if (fault.kind === undefined) return failure('unspellable-mark', `the ${type} spelling holds no ${fault.key} attribute`, path) - return failure('unspellable-mark', `the ${type} attribute ${fault.key} holds no ${fault.kind}`, path) +export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined { + const pairs = vocabularyPairs(mark.attrs ?? {}, vocabulary, undefined) + return pairs === undefined ? undefined : spellAttributes(pairs) } diff --git a/src/markdown-escaping.ts b/src/markdown-escaping.ts index 21cf506..e5e8342 100644 --- a/src/markdown-escaping.ts +++ b/src/markdown-escaping.ts @@ -4,15 +4,17 @@ export type EmphasisRole = 'close' | 'open' export type InlineEscaping = 'backslash' | 'bracketed' | 'none' +export type NodeRange = { first: number; last: number } + export type InlineSegment = - | { emphasis: EmphasisRole; escaping: 'none'; mark: string; text: string } + | { emphasis: EmphasisRole; escaping: 'none'; nodes: NodeRange; text: string } | { emphasis?: undefined; escaping: InlineEscaping; text: string } -export type AssembledLine = { line: string; unspellableMark: string | undefined } +export type AssembledLine = { line: string; unspellableRun: NodeRange | undefined } export type LineContainer = 'heading' | 'paragraph' | 'table-cell' -type DelimiterRun = { character: string; closeMark: string | undefined; end: number; openMark: string | undefined; start: number } +type DelimiterRun = { character: string; closeNodes: NodeRange | undefined; end: number; openNodes: NodeRange | undefined; start: number } const delimiters = ['*', '_', '`', '~'] @@ -72,15 +74,15 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A placements.push(output.length) output += scan.charAt(index) } - return { line: output, unspellableMark: unspellableMark(segments, output, placements) } + return { line: output, unspellableRun: unspellableRun(segments, output, placements) } } -function unspellableMark(segments: readonly InlineSegment[], output: string, placements: readonly number[]): string | undefined { +function unspellableRun(segments: readonly InlineSegment[], output: string, placements: readonly number[]): NodeRange | undefined { for (const run of delimiterRuns(segments, placements)) { const before = charAt(output, run.start - 1) const after = output.charAt(run.end) - if (run.openMark !== undefined && !isLeftFlanking(before, after)) return run.openMark - if (run.closeMark !== undefined && !isRightFlanking(before, after)) return run.closeMark + if (run.openNodes !== undefined && !isLeftFlanking(before, after)) return run.openNodes + if (run.closeNodes !== undefined && !isRightFlanking(before, after)) return run.closeNodes } return undefined } @@ -96,16 +98,16 @@ function delimiterRuns(segments: readonly InlineSegment[], placements: readonly const end = start + segment.text.length const previous = runs[runs.length - 1] if (previous !== undefined && previous.end === start && previous.character === segment.text.charAt(0)) { - previous.closeMark = previous.closeMark ?? (closes ? segment.mark : undefined) + previous.closeNodes = previous.closeNodes ?? (closes ? segment.nodes : undefined) previous.end = end - previous.openMark = previous.openMark ?? (closes ? undefined : segment.mark) + previous.openNodes = previous.openNodes ?? (closes ? undefined : segment.nodes) continue } runs.push({ character: segment.text.charAt(0), - closeMark: closes ? segment.mark : undefined, + closeNodes: closes ? segment.nodes : undefined, end, - openMark: closes ? undefined : segment.mark, + openNodes: closes ? undefined : segment.nodes, start, }) } diff --git a/src/markdown-inline.ts b/src/markdown-inline.ts index 6065e71..dd94a84 100644 --- a/src/markdown-inline.ts +++ b/src/markdown-inline.ts @@ -1,36 +1,42 @@ import type { AdfMark, AdfNode } from './adf-document.ts' import type { InlineDirective } from './inline-directives.ts' -import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer } from './markdown-escaping.ts' +import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './markdown-escaping.ts' import { inlineDirective, markSpelling, spellInlineNodeAttributes, spellMarkAttributes } from './inline-directives.ts' import { largestNesting } from './nesting.ts' -import { claimsLine, holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isAutolink, isUnicodeWhitespace } from './commonmark-grammar.ts' +import { claimsLine, holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isAutolink } from './commonmark-grammar.ts' import { carriedInline } from './opaque-carry.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { longestBacktickRun } from './backtick-runs.ts' import { serializeCanonicalJson } from './canonical-json.ts' import { spellAttributes, spellStringAttribute } from './directive-attributes.ts' +type EmittedLine = { line: string; segments: InlineSegment[] } + +type Emission = { carry: NodeRange; segments?: undefined } | { carry?: undefined; segments: InlineSegment[] } + type InlineContext = { atBlockEnd: boolean bracketed: boolean + carried: ReadonlySet path: ConvertErrorPath spansLines: boolean } type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode } +type LineAttempt = { carry: NodeRange; line?: undefined } | { carry?: undefined; line: string } + export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { - const segments = lineSegments(nodes, container, path) - if (!segments.ok) return segments - return finishLine(segments.value, container, path) + const emitted = emitLine(nodes, container, path) + if (!emitted.ok) return emitted + return success(emitted.value.line) } export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined { - const segments = lineSegments(nodes, 'table-cell', path) - if (!segments.ok) return undefined - if (segments.value.some((segment) => segment.escaping === 'none' && segment.text.includes('|'))) return undefined - const line = finishLine(segments.value, 'table-cell', path) - return line.ok ? line.value : undefined + const emitted = emitLine(nodes, 'table-cell', path) + if (!emitted.ok) return undefined + if (emitted.value.segments.some((segment) => segment.escaping === 'none' && segment.text.includes('|'))) return undefined + return emitted.value.line } export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined { @@ -38,23 +44,42 @@ export function tryImageLine(alt: string | undefined, href: string, path: Conver const destination = spellDestination(href, path) if (!destination.ok) return undefined const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }] - const line = finishLine([syntax('!['), ...description, syntax(`](${destination.value})`)], 'paragraph', path) - return line.ok ? line.value : undefined + const attempt = attemptLine([syntax('!['), ...description, syntax(`](${destination.value})`)], 'paragraph', path) + return attempt.ok ? attempt.value.line : undefined } -function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { - const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, bracketed: false, path, spansLines: container === 'paragraph' }) - if (!segments.ok) return segments - return success(carryStrippedWhitespace(segments.value)) -} - -function finishLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result { - const assembled = assembleInlineLine(segments, container) - if (assembled.unspellableMark !== undefined) { - return failure('unspellable-mark', `the ${assembled.unspellableMark} spelling cannot open or close where it sits`, path) +// A demand names a run no spelling holds, and a carried node joins no run, so every pass carries one more node. +function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { + const carried = new Set() + for (;;) { + const emission = lineSegments(nodes, container, path, carried) + if (!emission.ok) return emission + if (emission.value.carry !== undefined) { + carryRange(carried, emission.value.carry) + continue + } + const attempt = attemptLine(emission.value.segments, container, path) + if (!attempt.ok) return attempt + if (attempt.value.carry === undefined) return success({ line: attempt.value.line, segments: emission.value.segments }) + carryRange(carried, attempt.value.carry) } - const line = assembled.line - for (const [index, single] of line.split('\n').entries()) { +} + +function carryRange(carried: Set, range: NodeRange): void { + for (let index = range.first; index <= range.last; index += 1) carried.add(index) +} + +function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, carried: ReadonlySet): Result { + const emission = emitRun(nodes, 0, 0, { atBlockEnd: true, bracketed: false, carried, path, spansLines: container === 'paragraph' }) + if (!emission.ok) return emission + if (emission.value.carry !== undefined) return emission + return success({ segments: carryStrippedWhitespace(emission.value.segments) }) +} + +function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result { + const assembled = assembleInlineLine(segments, container) + if (assembled.unspellableRun !== undefined) return success({ carry: assembled.unspellableRun }) + for (const [index, single] of assembled.line.split('\n').entries()) { if (/^[ \t\v\f]|[ \t\v\f]$/.test(single)) { return failure('unspellable-whitespace', 'a line begins or ends with whitespace CommonMark strips', path) } @@ -62,7 +87,7 @@ function finishLine(segments: readonly InlineSegment[], container: LineContainer return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(single)}`, path) } } - return success(line) + return success({ line: assembled.line }) } // spec/flavour.md, Inline nodes. @@ -110,27 +135,28 @@ function refuseContentAndText(node: AdfNode, path: ConvertErrorPath): Result { +function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): Result { if (depth > largestNesting) { return failure('unsupported-node-shape', `the marks nest deeper than the ${largestNesting} levels the emitter carries`, context.path) } - const runs = inlineRuns(nodes, depth, firstIndex) + const runs = inlineRuns(nodes, depth, firstIndex, context.carried) const segments: InlineSegment[] = [] for (const [offset, run] of runs.entries()) { 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 - segments.push(...emitted.value) + if (emitted.value.carry !== undefined) return emitted + segments.push(...emitted.value.segments) } - return success(segments) + return success({ segments }) } -function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number): InlineRun[] { +function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet): InlineRun[] { const runs: InlineRun[] = [] for (const [offset, node] of nodes.entries()) { const index = firstIndex + offset // spec/flavour.md, Marks. - const mark = carries(node) ? undefined : (node.marks ?? [])[depth] + const mark = carries(node, carried, index) ? undefined : (node.marks ?? [])[depth] if (mark === undefined) { runs.push({ index, kind: 'plain', node }) continue @@ -146,49 +172,50 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath { return [...context.path, 'content', index] } -function carries(node: AdfNode): boolean { +function carries(node: AdfNode, carried: ReadonlySet, index: number): boolean { + if (carried.has(index)) return true return node.type !== 'hardBreak' && node.type !== 'text' && inlineDirective(node.type) === undefined } -function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result { +function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result { const path = nodePath(context, index) - if (carries(node)) { + if (carries(node, context.carried, index)) { const carried = carriedInline(node, path) if (!carried.ok) return carried - return success([syntax(carried.value)]) + return success({ segments: [syntax(carried.value)] }) } const types = (node.marks ?? []).map((mark) => mark.type) if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path) const directive = inlineDirective(node.type) - if (directive !== undefined) return emitInlineDirective(node, directive, path) + if (directive !== undefined) return emitInlineDirective(node, directive, index, path) if (node.type === 'hardBreak') return emitHardBreak(node, context, path) return emitText(node, context, path) } -function emitHardBreak(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result { +function emitHardBreak(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result { const unspelled = Object.keys(node.attrs ?? {})[0] if (unspelled !== undefined) return failure('unspelled-node-attribute', `the hardBreak attribute ${unspelled} has no canonical markdown spelling`, path) const empty = refuseContentAndText(node, path) if (!empty.ok) return empty - if (context.spansLines && !context.atBlockEnd) return success([syntax('\\\n')]) - return success([syntax(spellLeafDirective('hardBreak', ''))]) + if (context.spansLines && !context.atBlockEnd) return success({ segments: [syntax('\\\n')] }) + return success({ segments: [syntax(spellLeafDirective('hardBreak', ''))] }) } -function emitInlineDirective(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result { +function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: number, path: ConvertErrorPath): Result { const empty = refuseContentAndText(node, path) if (!empty.ok) return empty - const attributes = spellInlineNodeAttributes(node, directive, path) - if (!attributes.ok) return attributes + const attributes = spellInlineNodeAttributes(node, directive) + if (attributes === undefined) return success({ carry: { first: index, last: index } }) const slot = directive.slot === undefined ? undefined : node.attrs?.[directive.slot] - if (slot === undefined) return success([syntax(spellLeafDirective(node.type, attributes.value))]) - if (typeof slot !== 'string') return failure('unsupported-node-shape', `the ${node.type} attribute ${directive.slot} holds no string`, path) + if (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] }) + if (typeof slot !== 'string') return success({ carry: { first: index, last: index } }) if (/[\n\r]/.test(slot)) return failure('unspellable-whitespace', `a ${node.type} content slot holds a newline no inline directive spans`, path) if (holdsNullCharacter(slot)) return failure('unspellable-character', `a ${node.type} content slot holds a null character CommonMark replaces`, path) const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }] - return success([syntax(`:${node.type}[`), ...content, syntax(`]${attributes.value}`)]) + return success({ segments: [syntax(`:${node.type}[`), ...content, syntax(`]${attributes}`)] }) } -function emitText(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result { +function emitText(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result { const unspelled = Object.keys(node.attrs ?? {})[0] if (unspelled !== undefined) return failure('unspelled-node-attribute', `the text attribute ${unspelled} has no canonical markdown spelling`, path) if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text', path) @@ -197,60 +224,51 @@ function emitText(node: AdfNode, context: InlineContext, path: ConvertErrorPath) if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path) const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash' const parts = node.text.split(/(\n+)/).filter((part) => part !== '') - return success(parts.map((part) => (part.startsWith('\n') ? carriedText(part) : { escaping, text: part }))) + return success({ segments: parts.map((part) => (part.startsWith('\n') ? carriedText(part) : { escaping, text: part })) }) } -function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result { +function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result { const path = nodePath(context, index) + const range: NodeRange = { first: index, last: index + nodes.length - 1 } const spelling = markSpelling(mark.type) - if (spelling === undefined) return failure('unspellable-mark', `no markdown spelling holds the ${mark.type} mark`, path) - const attributes = spellMarkAttributes(mark, spelling.attributes, path) - if (!attributes.ok) return attributes - if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, path) - if (spelling.kind === 'emphasis') return emitEmphasis(nodes, mark, spelling.spelling, depth, index, context, path) - if (spelling.kind === 'link') return emitLink(nodes, mark, depth, index, context, path) + if (spelling === undefined) return success({ carry: range }) + const attributes = spellMarkAttributes(mark, spelling.attributes) + 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) + if (spelling.kind === 'link') return emitLink(nodes, mark, depth, range, context, path) const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false }) if (!inner.ok) return inner - return success([syntax(`:${mark.type}[`), ...inner.value, syntax(`]${attributes.value}`)]) + if (inner.value.carry !== undefined) return inner + return success({ segments: [syntax(`:${mark.type}[`), ...inner.value.segments, syntax(`]${attributes}`)] }) } -function emitEmphasis( - nodes: readonly AdfNode[], - mark: AdfMark, - spelling: string, - depth: number, - index: number, - context: InlineContext, - path: ConvertErrorPath, -): Result { - const inner = emitRun(nodes, depth + 1, index, context) +function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number, range: NodeRange, context: InlineContext): Result { + const inner = emitRun(nodes, depth + 1, range.first, context) if (!inner.ok) return inner - const carried = carryStrippedWhitespace(inner.value) - const text = carried.map((segment) => segment.text).join('') - if (holdsEdgeWhitespace(text)) return failure('unspellable-whitespace', `the ${mark.type} spelling cannot open or close beside whitespace`, path) - return success([ - { emphasis: 'open', escaping: 'none', mark: mark.type, text: spelling }, - ...carried, - { emphasis: 'close', escaping: 'none', mark: mark.type, text: spelling }, - ]) + if (inner.value.carry !== undefined) return inner + const carried = carryStrippedWhitespace(inner.value.segments) + return success({ + segments: [ + { emphasis: 'open', escaping: 'none', nodes: range, text: spelling }, + ...carried, + { emphasis: 'close', escaping: 'none', nodes: range, text: spelling }, + ], + }) } -function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErrorPath): Result { +function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result { let text = '' for (const node of nodes) { - if (node.type !== 'text' || typeof node.text !== 'string' || node.text === '') return failure('unspellable-mark', 'a code span holds text nodes only', path) - if ((node.marks ?? []).length !== depth + 1) return failure('unspellable-mark', 'a code span cannot sit inside the marks it carries', path) + if (node.type !== 'text' || (node.marks ?? []).length !== depth + 1) return success({ carry: range }) + if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text', path) text += node.text } - if (/[\n\r]/.test(text)) return failure('unspellable-mark', 'a code span holds no newline', path) + 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([syntax(`${fence}${padded}${fence}`)]) -} - -function holdsEdgeWhitespace(text: string): boolean { - return isUnicodeWhitespace(text.charAt(0)) || isUnicodeWhitespace(text.charAt(text.length - 1)) + return success({ segments: [syntax(`${fence}${padded}${fence}`)] }) } function needsPadding(text: string): boolean { @@ -258,20 +276,21 @@ function needsPadding(text: string): boolean { return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text) } -function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext, path: ConvertErrorPath): Result { +function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext, path: ConvertErrorPath): Result { const href = mark.attrs?.['href'] const title = mark.attrs?.['title'] - if (typeof href !== 'string') return failure('unsupported-node-shape', 'a link mark carries no href', path) + if (typeof href !== 'string') return success({ carry: range }) const node = nodes[0] const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && (node.marks ?? []).length === depth + 1 - if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success([syntax(`<${href}>`)]) + if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] }) const destination = spellDestination(href, path) if (!destination.ok) return destination const spelledTitle = typeof title === 'string' ? spellTitle(title, path) : success('') if (!spelledTitle.ok) return spelledTitle - const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true }) + const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true }) if (!inner.ok) return inner - return success([syntax('['), ...inner.value, syntax(`](${destination.value}${spelledTitle.value})`)]) + if (inner.value.carry !== undefined) return inner + return success({ segments: [syntax('['), ...inner.value.segments, syntax(`](${destination.value}${spelledTitle.value})`)] }) } function spellDestination(href: string, path: ConvertErrorPath): Result { diff --git a/src/result.ts b/src/result.ts index 4f735f9..533c8e2 100644 --- a/src/result.ts +++ b/src/result.ts @@ -8,7 +8,6 @@ export type ConvertErrorCode = | 'unspellable-link-destination' | 'unspellable-link-title' | 'unspellable-list-marker' - | 'unspellable-mark' | 'unspellable-whitespace' | 'unspelled-block-marks' | 'unspelled-block-separation' diff --git a/todo.md b/todo.md index 52b09a5..f17a9fd 100644 --- a/todo.md +++ b/todo.md @@ -103,7 +103,7 @@ detail is settled at its own milestone. CommonMark's own precedence between a directive and a code span, and collapsed `escaping: 'attribute'` into `none`. The escaper's link-opener scan skips emitted syntax to match: a `](` inside a directive escapes no text `[`. - - [ ] **2e4 — The carry's fallback triggers.** `spec/flavour.md` carries a node its section + - [x] **2e4 — The carry's fallback triggers.** `spec/flavour.md` carries a node its section cannot spell — an attrs key no section lists, a value that is not the section's type, an arg slot holding no bare token, marks no nesting spells — where the emitter still refuses, which leaves the refusals a container's own spelling owns. The flanking trigger 2e2