From b1f254bca9776806e0f2b2f433f3c8ab971a4e22 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 27 Aug 2026 15:35:24 +0200 Subject: [PATCH] Answer the review: one shape predicate, and the readable spellings give way --- AGENTS.md | 8 ++-- .../block-nodes/code-block-attributes.json | 21 ++++++++++ .../block-nodes/code-block-attributes.md | 8 ++++ spec/flavour.md | 27 +++++++------ src/adf-document.ts | 5 +++ src/adf-to-markdown.test.ts | 35 +++++++++++------ src/adf-to-markdown.ts | 38 +++++++------------ src/block-directives.ts | 3 +- src/directive-attributes.ts | 6 +-- src/inline-directives.ts | 2 +- src/markdown-image.ts | 7 ++-- src/markdown-inline.ts | 7 ++-- src/markdown-pipe-table.ts | 13 +++---- src/result.ts | 1 - todo.md | 4 +- 15 files changed, 108 insertions(+), 77 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 08a92e1..add34ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,9 +125,11 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga `matchEmphasis` transcribes the reference `process_emphasis` line for line, and its closer walk and opener search stay whole: broken into named steps they drift from the algorithm being faithful is the whole point of. -- A readable spelling tried ahead of a general one — the image, the pipe table, a pipe cell — - returns `string | undefined`, never a `Result`: any failure is the fallback signal, and the - general form owns the refusal. Refusing there refuses a document the general form spells. +- A readable spelling tried ahead of a general one — a CommonMark block, the image, the pipe + table, a pipe cell — gives way with `undefined` for every shape it cannot spell, and fails only + where the general form fails on the same node. Refusing there refuses a document the general + form spells, so a refusal the general form does not share belongs in the general form or + nowhere. - Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an attribute's and a carried node's alike — are all held to 500 levels, so a deep document is a `Result` rather than the stack overflow that waits near 2000. diff --git a/corpus/round-trip/block-nodes/code-block-attributes.json b/corpus/round-trip/block-nodes/code-block-attributes.json index 022ab0b..383c929 100644 --- a/corpus/round-trip/block-nodes/code-block-attributes.json +++ b/corpus/round-trip/block-nodes/code-block-attributes.json @@ -51,6 +51,27 @@ } ], "type": "codeBlock" + }, + { + "attrs": { + "panelType": "info" + }, + "content": [ + { + "attrs": { + "language": "text", + "wrap": true + }, + "content": [ + { + "text": ":::", + "type": "text" + } + ], + "type": "codeBlock" + } + ], + "type": "panel" } ], "type": "doc", diff --git a/corpus/round-trip/block-nodes/code-block-attributes.md b/corpus/round-trip/block-nodes/code-block-attributes.md index 243d530..1684d8d 100644 --- a/corpus/round-trip/block-nodes/code-block-attributes.md +++ b/corpus/round-trip/block-nodes/code-block-attributes.md @@ -21,3 +21,11 @@ SELECT 1 y ``` ::: + +::::panel info +:::codeBlock {wrap=true} +```text +::: +``` +::: +:::: diff --git a/spec/flavour.md b/spec/flavour.md index 4a052b8..ea66cfc 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -18,8 +18,9 @@ normalizes to it through the round-trip. inline, so a `~~~` line opens a CommonMark tilde code fence. - Bullet lists `- `; ordered lists incrementing `1.` `2.` `3.`, the first number the node's `order` attribute. Continuation lines align with the first character after the marker - (two spaces for `- `, three for `1. `); blank lines inside an item are empty lines, none before - a nested list. Blank lines between items normalize away; ADF does not record tightness. + (two spaces for `- `, three for `1. `); blank lines inside an item are empty lines, none + between a nested list and a CommonMark block above it. Blank lines between items normalize + away; ADF does not record tightness. - Blockquotes prefix lines with `> `; a blank line inside a blockquote is a bare `>`. - ATX headings (`#` … `######`); setext input normalizes to ATX. - Code fences ``` with the node's language as info string, the fence lengthened past any backtick @@ -31,8 +32,8 @@ normalizes to it through the round-trip. inside it, directive syntax and `~~` included. - Thematic break `---`. - Hard break: backslash at end of line (survives editors that trim trailing spaces). Where - CommonMark admits no spelling — the end of a block, inside a heading — or where the node carries - an attribute, it is the inline directive. + CommonMark admits no spelling — the end of a block, inside a heading — or where the node + carries an attribute, it is the inline directive. - An empty paragraph — real payloads carry them — is `::paragraph`. - Links `[text](url)`; `<…>` around a destination containing spaces; title in double quotes. `` autolink form only when the text equals the destination and the destination is a valid @@ -155,9 +156,8 @@ editor-normal ADF reads an empty attrs object, marks array or content array as t Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a `json` value: `::::layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`. -Three child nodes hold inline content rather than blocks (`caption`, `decisionItem`, -`taskItem`): their body is at most one paragraph, whose inline content becomes the node's -`content`; any other body is a named error. +A section saying its body is inline takes at most one paragraph, whose inline content becomes +the node's `content`; any other body is a named error, and a node holding no content is the leaf. A node the sections cannot spell rides the opaque carry: an attrs key its section does not list, a value that is not the section's type, or an arg-slot value that is no bare token. In @@ -181,8 +181,7 @@ form. - `orderedList` — container of `listItem`, block body. Attributes: `localId`, `order` (number). `order` is the first marker, so a list carrying none, or one whose markers would run past 999999999, has no CommonMark spelling. -- `paragraph` — container, inline body; holding no content it is the leaf `::paragraph`. No - attributes beyond `localId`. +- `paragraph` — container, inline body; no attributes beyond `localId`. - `rule` — leaf; no attributes beyond `localId`. ```` @@ -364,11 +363,11 @@ attributes. The other three share: `extensionKey` (string), `extensionType` (str Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot -as plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content parsing to anything -but one unmarked text node — adjacent identical-mark text nodes merged first — is a named -error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the -slot. The rest take no content, `:text` included; content on a node that takes none is a named -error. +as plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content +parsing to anything but one unmarked text node — adjacent identical-mark text nodes merged first — +is a named error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach +into the slot. The rest take no content, `:text` included; content on a node that takes none is a +named error. - `date` — `localId` (string), `timestamp` (string, epoch milliseconds). - `emoji` — `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` (string). diff --git a/src/adf-document.ts b/src/adf-document.ts index 0bd22a6..f85ac28 100644 --- a/src/adf-document.ts +++ b/src/adf-document.ts @@ -29,6 +29,11 @@ const documentKeys = ['content', 'type', 'version'] const markKeys = ['attrs', 'type'] const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type'] +export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean { + if ((node.marks ?? []).length > 0 || node.text !== undefined) return false + return holdsOnly(node.attrs ?? {}, attributes) +} + export function isAdfDocument(value: unknown): value is AdfDocument { if (!isRecord(value) || !holdsOnly(value, documentKeys)) return false if (value['type'] !== 'doc') return false diff --git a/src/adf-to-markdown.test.ts b/src/adf-to-markdown.test.ts index dd032a3..30dea40 100644 --- a/src/adf-to-markdown.test.ts +++ b/src/adf-to-markdown.test.ts @@ -41,8 +41,11 @@ test('refuses a document version the markdown cannot carry', () => { assert.equal(code(adfToMarkdown({ type: 'doc', version: 2 })), 'unsupported-document-version') }) -test('refuses a text node attribute the canonical form does not spell', () => { - assert.equal(code(adfToMarkdown(document(paragraph({ attrs: { localId: 'a' }, text: 'x', type: 'text' })))), 'unspelled-node-attribute') +test('carries a text node attribute no spelling holds', () => { + assert.equal( + markdown(adfToMarkdown(document(paragraph({ attrs: { localId: 'a' }, text: 'x', type: 'text' })))), + ':adf{json="{\\"attrs\\":{\\"localId\\":\\"a\\"},\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n', + ) }) test('spells a CommonMark block as a directive where its own spelling holds neither attribute nor mark', () => { @@ -160,9 +163,12 @@ test('refuses a carried node nested deeper than the emitter carries', () => { test('refuses a node whose content model the canonical form cannot emit', () => { assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(document({ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape') - assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), 'unsupported-node-shape') - assert.equal(code(adfToMarkdown(document({ type: 'bulletList' }))), 'unsupported-node-shape') - assert.equal(code(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), 'unsupported-node-shape') +}) + +test('spells a list its own markers cannot hold as a directive', () => { + assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), ':::bulletList\n::paragraph\n:::\n') + assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), ':::bulletList\n:::\n') + assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), ':::orderedList {order=2}\n:::\n') }) test('spells an ordered list no marker fits as a directive', () => { @@ -321,12 +327,12 @@ test('escapes a hyphen underline a hard break would expose', () => { assert.equal(line('=='), 'foo\\\n\\==\n') }) -test('refuses a list item whose marker completes a thematic break', () => { +test('spells a list item whose marker completes a thematic break as a directive', () => { const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' }) - assert.equal(code(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), 'unspellable-line-start') + assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '::::bulletList\n:::listItem\n---\n:::\n::::\n') const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' } assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n') - assert.equal(code(adfToMarkdown(document({ content: [item(nested)], type: 'bulletList' }))), 'unspellable-line-start') + assert.equal(markdown(adfToMarkdown(document({ content: [item(nested)], type: 'bulletList' }))), '::::bulletList\n:::listItem\n- -\n:::\n::::\n') }) test('refuses the characters CommonMark rewrites', () => { @@ -382,7 +388,8 @@ 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' }))), ':::panel warning\nx\n:::\n') assert.equal(markdown(adfToMarkdown(panel({}))), ':::panel\nx\n:::\n') - assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), ':::caption\n:::\n') + assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), ':::caption\nx\n:::\n') + assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '::caption\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '::syncBlock {localId=a}\n') }) @@ -429,9 +436,10 @@ test('separates two directive blocks in a container body by one line, two Common const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' }) const panel = (...content: AdfNode[]): AdfDocument => document({ attrs: { panelType: 'info' }, content, type: 'panel' }) assert.equal(markdown(adfToMarkdown(panel(text('a'), text('b')))), ':::panel info\na\n\nb\n:::\n') - assert.equal(markdown(adfToMarkdown(panel({ type: 'caption' }, { type: 'caption' }))), '::::panel info\n:::caption\n:::\n:::caption\n:::\n::::\n') - assert.equal(code(adfToMarkdown(panel(text('a'), { type: 'caption' }))), 'unspelled-block-separation') - assert.equal(code(adfToMarkdown(panel({ type: 'caption' }, text('a')))), 'unspelled-block-separation') + const caption: AdfNode = { content: [{ text: 'c', type: 'text' }], type: 'caption' } + assert.equal(markdown(adfToMarkdown(panel(caption, caption))), '::::panel info\n:::caption\nc\n:::\n:::caption\nc\n:::\n::::\n') + assert.equal(code(adfToMarkdown(panel(text('a'), caption))), 'unspelled-block-separation') + assert.equal(code(adfToMarkdown(panel(caption, text('a')))), 'unspelled-block-separation') assert.equal(code(adfToMarkdown(panel(paragraph(), text('a')))), 'unspelled-block-separation') }) @@ -451,6 +459,9 @@ test('spells the image form for exactly the centered external media 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') + const media: AdfNode = { attrs: { type: 'external', url }, type: 'media' } + assert.equal(code(adfToMarkdown(document({ attrs: { layout: 'center' }, content: [media], text: 'x', type: 'mediaSingle' }))), 'unsupported-node-shape') + assert.equal(code(adfToMarkdown(document({ attrs: { layout: 'center' }, content: [{ ...media, text: 'x' }], type: 'mediaSingle' }))), 'unsupported-node-shape') }) test('spells a mediaSingle the image form does not fit as a directive', () => { diff --git a/src/adf-to-markdown.ts b/src/adf-to-markdown.ts index bcd61bf..61a5f1d 100644 --- a/src/adf-to-markdown.ts +++ b/src/adf-to-markdown.ts @@ -8,7 +8,7 @@ import { tryPipeTable } from './markdown-pipe-table.ts' import { carriedBlock, carryName } from './opaque-carry.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { holdsEntityReference, holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts' -import { isAdfDocument } from './adf-document.ts' +import { carriesOnly, isAdfDocument } from './adf-document.ts' import { largestNesting } from './nesting.ts' import { fencedCodeBlock } from './backtick-runs.ts' @@ -120,8 +120,7 @@ function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: Conv if (directive.body === 'code') return emitCodeDirective(node, directive, path) const header = spellDirectiveHeader(node, directive) if (header === undefined) return commonMarkLine(carriedBlock(node, path)) - // spec/flavour.md, The CommonMark blocks: an empty paragraph is the leaf. - if (directive.body === 'none' || (node.type === 'paragraph' && content.length === 0)) { + if (directive.body === 'none' || (directive.body === 'inline' && content.length === 0)) { return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` }) } const body = directive.body === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1) @@ -140,7 +139,7 @@ function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Re } function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result | undefined { - if (!commonMarkHead(node, [])) return undefined + if (!carriesOnly(node, [])) return undefined const inner = emitBlocks(node.content ?? [], 'document', path, depth + 1) if (!inner.ok) return inner const text = inner.value.text @@ -151,7 +150,7 @@ function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): R } function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result | undefined { - if (!commonMarkHead(node, ['language'])) return undefined + if (!carriesOnly(node, ['language'])) return undefined const info = fenceInfo(node.attrs?.['language']) if (info === undefined) return undefined const text = codeBlockText(node, path) @@ -198,7 +197,7 @@ function fenceInfo(language: JsonValue | undefined): string | undefined { } function emitHeading(node: AdfNode, path: ConvertErrorPath): Result | undefined { - if (!commonMarkHead(node, ['level'])) return undefined + if (!carriesOnly(node, ['level'])) return undefined const level = node.attrs?.['level'] if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined const hashes = '#'.repeat(level) @@ -211,17 +210,16 @@ function emitHeading(node: AdfNode, path: ConvertErrorPath): Result | undefined { const ordered = node.type === 'orderedList' - if (!commonMarkHead(node, ordered ? ['order'] : [])) return undefined + if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined const items = node.content ?? [] const start = listStart(node, items.length) - if (start === undefined || items.some((item) => !commonMarkHead(item, []))) return undefined - if (items.length === 0) return failure('unsupported-node-shape', `a ${node.type} holds at least one listItem`, path) + if (start === undefined || items.length === 0) return undefined + if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined const lines: string[] = [] let fenceColons = 0 for (const [offset, item] of items.entries()) { - const itemPath = [...path, 'content', offset] - if (item.type !== 'listItem') return failure('unsupported-node-shape', `a ${node.type} holds listItem nodes only`, itemPath) - const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ', itemPath, depth) + const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ', [...path, 'content', offset], depth) + if (emitted === undefined) return undefined if (!emitted.ok) return emitted fenceColons = Math.max(fenceColons, emitted.value.fenceColons) lines.push(emitted.value.text) @@ -236,33 +234,25 @@ function listStart(node: AdfNode, items: number): number | undefined { return start + items - 1 > largestListMarker ? undefined : start } -function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result { +function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result | undefined { const inner = emitBlocks(item.content ?? [], 'list-item', path, depth + 1) if (!inner.ok) return inner if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() }) const indent = ' '.repeat(marker.length) const lines = inner.value.text.split('\n').map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`)) - const first = lines[0] ?? '' - if (isThematicBreak(first)) { - return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(first)}`, path) - } + if (isThematicBreak(lines[0] ?? '')) return undefined return success({ fenceColons: inner.value.fenceColons, text: lines.join('\n') }) } function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result | undefined { const content = node.content ?? [] - if (content.length === 0 || !commonMarkHead(node, [])) return undefined + if (content.length === 0 || !carriesOnly(node, [])) return undefined const line = emitInlineLine(content, 'paragraph', path) if (!line.ok) return line return success(commonMarkText(line.value)) } function emitRule(node: AdfNode): Result | undefined { - if (!commonMarkHead(node, []) || (node.content ?? []).length > 0) return undefined + if (!carriesOnly(node, []) || (node.content ?? []).length > 0) return undefined return success(commonMarkText('---')) } - -function commonMarkHead(node: AdfNode, spelled: readonly string[]): boolean { - if ((node.marks ?? []).length > 0 || node.text !== undefined) return false - return Object.keys(node.attrs ?? {}).every((key) => spelled.includes(key)) -} diff --git a/src/block-directives.ts b/src/block-directives.ts index 0f42fbf..817689e 100644 --- a/src/block-directives.ts +++ b/src/block-directives.ts @@ -93,7 +93,8 @@ export function blockDirective(type: string): BlockDirective | undefined { export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined { const argument = spellArgument(node, directive) if (argument === undefined) return undefined - const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, [directive.argument, ...spelledByBody]) + const spelled = directive.argument === undefined ? spelledByBody : [directive.argument, ...spelledByBody] + const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, spelled) if (pairs === undefined) return undefined const marks = node.marks ?? [] if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))]) diff --git a/src/directive-attributes.ts b/src/directive-attributes.ts index 12440bc..b93492d 100644 --- a/src/directive-attributes.ts +++ b/src/directive-attributes.ts @@ -11,11 +11,7 @@ export function isBareToken(text: string): boolean { return bareToken.test(text) } -export function vocabularyPairs( - attrs: AdfAttributes, - vocabulary: AttributeVocabulary, - spelledElsewhere: readonly (string | undefined)[], -): [string, string][] | undefined { +export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, spelledElsewhere: readonly string[]): [string, string][] | undefined { const pairs: [string, string][] = [] for (const [key, value] of Object.entries(attrs)) { if (spelledElsewhere.includes(key)) continue diff --git a/src/inline-directives.ts b/src/inline-directives.ts index b90df4f..eaad116 100644 --- a/src/inline-directives.ts +++ b/src/inline-directives.ts @@ -53,7 +53,7 @@ export function markSpelling(type: string): MarkSpelling | undefined { } export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined { - const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, [directive.slot]) + const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot === undefined ? [] : [directive.slot]) return pairs === undefined ? undefined : spellAttributes(pairs) } diff --git a/src/markdown-image.ts b/src/markdown-image.ts index 77b7cd8..1eaf509 100644 --- a/src/markdown-image.ts +++ b/src/markdown-image.ts @@ -1,4 +1,5 @@ import type { AdfNode } from './adf-document.ts' +import { carriesOnly } from './adf-document.ts' import type { ConvertErrorPath } from './result.ts' import { serializeCanonicalJson } from './canonical-json.ts' import { tryImageLine } from './markdown-inline.ts' @@ -15,12 +16,12 @@ export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefi function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined { const content = node.content ?? [] const media = content[0] - if (serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle || (node.marks ?? []).length > 0) return undefined - if (media === undefined || content.length !== 1 || media.type !== 'media' || (media.marks ?? []).length > 0 || (media.content ?? []).length > 0) return undefined + if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle) return undefined + if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || (media.content ?? []).length > 0) return undefined const attrs = media.attrs ?? {} const alt = attrs['alt'] const url = attrs['url'] - if (Object.keys(attrs).some((key) => !imageAttributes.includes(key)) || attrs['type'] !== 'external' || typeof url !== 'string') return undefined + if (attrs['type'] !== 'external' || typeof url !== 'string') return undefined if (alt !== undefined && (typeof alt !== 'string' || alt === '')) return undefined return { alt, url } } diff --git a/src/markdown-inline.ts b/src/markdown-inline.ts index c6dc5f4..b1bdea8 100644 --- a/src/markdown-inline.ts +++ b/src/markdown-inline.ts @@ -187,7 +187,7 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result< 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 emitText(node, context, path) + if (directive === undefined) return emitText(node, context, index, path) if (node.type === 'hardBreak') return emitHardBreak(node, directive, context, index, path) return emitInlineDirective(node, directive, index, path) } @@ -215,9 +215,8 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n return success({ segments: [syntax(`:${node.type}[`), ...content, syntax(`]${attributes}`)] }) } -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) +function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result { + if (Object.keys(node.attrs ?? {}).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', path) if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content', path) if (/\r/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a carriage return CommonMark rewrites', path) diff --git a/src/markdown-pipe-table.ts b/src/markdown-pipe-table.ts index 0487fbc..8756a69 100644 --- a/src/markdown-pipe-table.ts +++ b/src/markdown-pipe-table.ts @@ -1,4 +1,5 @@ import type { AdfNode } from './adf-document.ts' +import { carriesOnly } from './adf-document.ts' import { tryPipeCell } from './markdown-inline.ts' import type { ConvertErrorPath } from './result.ts' @@ -23,16 +24,16 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un function pipeRows(node: AdfNode): AdfNode[][] | undefined { const rows = node.content ?? [] const columns = (rows[0]?.content ?? []).length - if (!isPlain(node) || columns === 0) return undefined + if (!carriesOnly(node, []) || columns === 0) return undefined const grid: AdfNode[][] = [] for (const [index, row] of rows.entries()) { const cells = row.content ?? [] - if (row.type !== 'tableRow' || !isPlain(row) || cells.length !== columns) return undefined + if (row.type !== 'tableRow' || !carriesOnly(row, []) || cells.length !== columns) return undefined const wanted = index === 0 ? 'tableHeader' : 'tableCell' const paragraphs: AdfNode[] = [] for (const cell of cells) { const paragraph = plainParagraph(cell) - if (paragraph === undefined || cell.type !== wanted || !isPlain(cell)) return undefined + if (paragraph === undefined || cell.type !== wanted || !carriesOnly(cell, [])) return undefined paragraphs.push(paragraph) } grid.push(paragraphs) @@ -40,13 +41,9 @@ function pipeRows(node: AdfNode): AdfNode[][] | undefined { return grid } -function isPlain(node: AdfNode): boolean { - return Object.keys(node.attrs ?? {}).length === 0 && (node.marks ?? []).length === 0 && node.text === undefined -} - function plainParagraph(cell: AdfNode): AdfNode | undefined { const content = cell.content ?? [] const paragraph = content[0] - if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !isPlain(paragraph)) return undefined + if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !carriesOnly(paragraph, [])) return undefined return paragraph } diff --git a/src/result.ts b/src/result.ts index ec82a8b..6c705a9 100644 --- a/src/result.ts +++ b/src/result.ts @@ -7,7 +7,6 @@ export type ConvertErrorCode = | 'unspellable-link-title' | 'unspellable-whitespace' | 'unspelled-block-separation' - | 'unspelled-node-attribute' | 'unsupported-document-version' | 'unsupported-node-shape' diff --git a/todo.md b/todo.md index 501dbe8..855bf6a 100644 --- a/todo.md +++ b/todo.md @@ -65,7 +65,9 @@ detail is settled at its own milestone. the inline carry. `commonmark-subset/` green. - [x] **2b — Block nodes.** `block-nodes/` green. A nested list that cannot interrupt the block above it is refused meanwhile, not spelled: the maintainer's answer on tight-versus-blank - separation turns that refusal into an emission. Block separation becomes + separation turns that refusal into an emission. The test is broader than the name it + carries — `interruptsParagraph` reads the next list alone, so a list after a block no + paragraph continues, a code block say, is refused too — and the same answer narrows it. Block separation becomes `separationBetween(previous, next, container)` here — a boolean cannot hold the third case `spec/flavour.md` states for two directive blocks in a container body, and the maintainer's answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give