Answer the review: one shape predicate, and the readable spellings give way
CI / gate (push) Successful in 5s

This commit is contained in:
2026-08-27 15:35:24 +02:00
parent 59b42ef0dc
commit b1f254bca9
15 changed files with 108 additions and 77 deletions
+5 -3
View File
@@ -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 `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 opener search stay whole: broken into named steps they drift from the algorithm being faithful is
the whole point of. the whole point of.
- A readable spelling tried ahead of a general one — the image, the pipe table, a pipe cell — - A readable spelling tried ahead of a general one — a CommonMark block, the image, the pipe
returns `string | undefined`, never a `Result`: any failure is the fallback signal, and the table, a pipe cell — gives way with `undefined` for every shape it cannot spell, and fails only
general form owns the refusal. Refusing there refuses a document the general form spells. 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 - 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 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. `Result` rather than the stack overflow that waits near 2000.
@@ -51,6 +51,27 @@
} }
], ],
"type": "codeBlock" "type": "codeBlock"
},
{
"attrs": {
"panelType": "info"
},
"content": [
{
"attrs": {
"language": "text",
"wrap": true
},
"content": [
{
"text": ":::",
"type": "text"
}
],
"type": "codeBlock"
}
],
"type": "panel"
} }
], ],
"type": "doc", "type": "doc",
@@ -21,3 +21,11 @@ SELECT 1
y y
``` ```
::: :::
::::panel info
:::codeBlock {wrap=true}
```text
:::
```
:::
::::
+13 -14
View File
@@ -18,8 +18,9 @@ normalizes to it through the round-trip.
inline, so a `~~~` line opens a CommonMark tilde code fence. inline, so a `~~~` line opens a CommonMark tilde code fence.
- Bullet lists `- `; ordered lists incrementing `1.` `2.` `3.`, the first number the node's - 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 `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 (two spaces for `- `, three for `1. `); blank lines inside an item are empty lines, none
a nested list. Blank lines between items normalize away; ADF does not record tightness. 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 `>`. - Blockquotes prefix lines with `> `; a blank line inside a blockquote is a bare `>`.
- ATX headings (`#``######`); setext input normalizes to ATX. - ATX headings (`#``######`); setext input normalizes to ATX.
- Code fences ``` with the node's language as info string, the fence lengthened past any backtick - 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. inside it, directive syntax and `~~` included.
- Thematic break `---`. - Thematic break `---`.
- Hard break: backslash at end of line (survives editors that trim trailing spaces). Where - 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 CommonMark admits no spelling — the end of a block, inside a heading — or where the node
an attribute, it is the inline directive. carries an attribute, it is the inline directive.
- An empty paragraph — real payloads carry them — is `::paragraph`. - An empty paragraph — real payloads carry them — is `::paragraph`.
- Links `[text](url)`; `<…>` around a destination containing spaces; title in double quotes. - Links `[text](url)`; `<…>` around a destination containing spaces; title in double quotes.
`<url>` autolink form only when the text equals the destination and the destination is a valid `<url>` 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 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\"}]"}`. `json` value: `::::layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`.
Three child nodes hold inline content rather than blocks (`caption`, `decisionItem`, A section saying its body is inline takes at most one paragraph, whose inline content becomes
`taskItem`): their body is at most one paragraph, whose inline content becomes the node's the node's `content`; any other body is a named error, and a node holding no content is the leaf.
`content`; any other body is a named error.
A node the sections cannot spell rides the opaque carry: an attrs key its section does not 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 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). - `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 `order` is the first marker, so a list carrying none, or one whose markers would run past
999999999, has no CommonMark spelling. 999999999, has no CommonMark spelling.
- `paragraph` — container, inline body; holding no content it is the leaf `::paragraph`. No - `paragraph` — container, inline body; no attributes beyond `localId`.
attributes beyond `localId`.
- `rule` — leaf; 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 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 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 as plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
but one unmarked text node — adjacent identical-mark text nodes merged first — is a named parsing to anything but one unmarked text node — adjacent identical-mark text nodes merged first —
error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the is a named error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach
slot. The rest take no content, `:text` included; content on a node that takes none is a named into the slot. The rest take no content, `:text` included; content on a node that takes none is a
error. named error.
- `date` — `localId` (string), `timestamp` (string, epoch milliseconds). - `date` — `localId` (string), `timestamp` (string, epoch milliseconds).
- `emoji` — `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` (string). - `emoji` — `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` (string).
+5
View File
@@ -29,6 +29,11 @@ const documentKeys = ['content', 'type', 'version']
const markKeys = ['attrs', 'type'] const markKeys = ['attrs', 'type']
const nodeKeys = ['attrs', 'content', 'marks', 'text', '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 { export function isAdfDocument(value: unknown): value is AdfDocument {
if (!isRecord(value) || !holdsOnly(value, documentKeys)) return false if (!isRecord(value) || !holdsOnly(value, documentKeys)) return false
if (value['type'] !== 'doc') return false if (value['type'] !== 'doc') return false
+23 -12
View File
@@ -41,8 +41,11 @@ test('refuses a document version the markdown cannot carry', () => {
assert.equal(code(adfToMarkdown({ type: 'doc', version: 2 })), 'unsupported-document-version') assert.equal(code(adfToMarkdown({ type: 'doc', version: 2 })), 'unsupported-document-version')
}) })
test('refuses a text node attribute the canonical form does not spell', () => { test('carries a text node attribute no spelling holds', () => {
assert.equal(code(adfToMarkdown(document(paragraph({ attrs: { localId: 'a' }, text: 'x', type: 'text' })))), 'unspelled-node-attribute') 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', () => { 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', () => { 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: [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: [{ 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', () => { 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') 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' }) 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' } const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' }
assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n') 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', () => { 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' }) 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({ panelType: 'warning' }))), ':::panel warning\nx\n:::\n')
assert.equal(markdown(adfToMarkdown(panel({}))), ':::panel\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') 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 text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
const panel = (...content: AdfNode[]): AdfDocument => document({ attrs: { panelType: 'info' }, content, type: 'panel' }) 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(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') const caption: AdfNode = { content: [{ text: 'c', type: 'text' }], type: 'caption' }
assert.equal(code(adfToMarkdown(panel(text('a'), { type: 'caption' }))), 'unspelled-block-separation') assert.equal(markdown(adfToMarkdown(panel(caption, caption))), '::::panel info\n:::caption\nc\n:::\n:::caption\nc\n:::\n::::\n')
assert.equal(code(adfToMarkdown(panel({ type: 'caption' }, text('a')))), 'unspelled-block-separation') 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') 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({ alt: 4, type: 'external', url }))
assert.ok(fallback({ type: 'external', url: 4 })) assert.ok(fallback({ type: 'external', url: 4 }))
assert.equal(code(adfToMarkdown(single({ type: 'external', url }, paragraph()))), 'unsupported-node-shape') 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', () => { test('spells a mediaSingle the image form does not fit as a directive', () => {
+14 -24
View File
@@ -8,7 +8,7 @@ import { tryPipeTable } from './markdown-pipe-table.ts'
import { carriedBlock, carryName } from './opaque-carry.ts' import { carriedBlock, carryName } from './opaque-carry.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { holdsEntityReference, holdsNullCharacter, isThematicBreak } from './commonmark-grammar.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 { largestNesting } from './nesting.ts'
import { fencedCodeBlock } from './backtick-runs.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) if (directive.body === 'code') return emitCodeDirective(node, directive, path)
const header = spellDirectiveHeader(node, directive) const header = spellDirectiveHeader(node, directive)
if (header === undefined) return commonMarkLine(carriedBlock(node, path)) if (header === undefined) return commonMarkLine(carriedBlock(node, path))
// spec/flavour.md, The CommonMark blocks: an empty paragraph is the leaf. if (directive.body === 'none' || (directive.body === 'inline' && content.length === 0)) {
if (directive.body === 'none' || (node.type === 'paragraph' && content.length === 0)) {
return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` }) return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` })
} }
const body = directive.body === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1) 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<EmittedBlock> | undefined { function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
if (!commonMarkHead(node, [])) return undefined if (!carriesOnly(node, [])) return undefined
const inner = emitBlocks(node.content ?? [], 'document', path, depth + 1) const inner = emitBlocks(node.content ?? [], 'document', path, depth + 1)
if (!inner.ok) return inner if (!inner.ok) return inner
const text = inner.value.text 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<EmittedBlock> | undefined { function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!commonMarkHead(node, ['language'])) return undefined if (!carriesOnly(node, ['language'])) return undefined
const info = fenceInfo(node.attrs?.['language']) const info = fenceInfo(node.attrs?.['language'])
if (info === undefined) return undefined if (info === undefined) return undefined
const text = codeBlockText(node, path) const text = codeBlockText(node, path)
@@ -198,7 +197,7 @@ function fenceInfo(language: JsonValue | undefined): string | undefined {
} }
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!commonMarkHead(node, ['level'])) return undefined if (!carriesOnly(node, ['level'])) return undefined
const level = node.attrs?.['level'] const level = node.attrs?.['level']
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
const hashes = '#'.repeat(level) const hashes = '#'.repeat(level)
@@ -211,17 +210,16 @@ function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock
function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined { function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
const ordered = node.type === 'orderedList' const ordered = node.type === 'orderedList'
if (!commonMarkHead(node, ordered ? ['order'] : [])) return undefined if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined
const items = node.content ?? [] const items = node.content ?? []
const start = listStart(node, items.length) const start = listStart(node, items.length)
if (start === undefined || items.some((item) => !commonMarkHead(item, []))) return undefined if (start === undefined || items.length === 0) return undefined
if (items.length === 0) return failure('unsupported-node-shape', `a ${node.type} holds at least one listItem`, path) if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined
const lines: string[] = [] const lines: string[] = []
let fenceColons = 0 let fenceColons = 0
for (const [offset, item] of items.entries()) { for (const [offset, item] of items.entries()) {
const itemPath = [...path, 'content', offset] const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ', [...path, 'content', offset], depth)
if (item.type !== 'listItem') return failure('unsupported-node-shape', `a ${node.type} holds listItem nodes only`, itemPath) if (emitted === undefined) return undefined
const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ', itemPath, depth)
if (!emitted.ok) return emitted if (!emitted.ok) return emitted
fenceColons = Math.max(fenceColons, emitted.value.fenceColons) fenceColons = Math.max(fenceColons, emitted.value.fenceColons)
lines.push(emitted.value.text) lines.push(emitted.value.text)
@@ -236,33 +234,25 @@ function listStart(node: AdfNode, items: number): number | undefined {
return start + items - 1 > largestListMarker ? undefined : start return start + items - 1 > largestListMarker ? undefined : start
} }
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<EmittedBody> { function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<EmittedBody> | undefined {
const inner = emitBlocks(item.content ?? [], 'list-item', path, depth + 1) const inner = emitBlocks(item.content ?? [], 'list-item', path, depth + 1)
if (!inner.ok) return inner if (!inner.ok) return inner
if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() }) if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() })
const indent = ' '.repeat(marker.length) const indent = ' '.repeat(marker.length)
const lines = inner.value.text.split('\n').map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`)) const lines = inner.value.text.split('\n').map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
const first = lines[0] ?? '' if (isThematicBreak(lines[0] ?? '')) return undefined
if (isThematicBreak(first)) {
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(first)}`, path)
}
return success({ fenceColons: inner.value.fenceColons, text: lines.join('\n') }) return success({ fenceColons: inner.value.fenceColons, text: lines.join('\n') })
} }
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
const content = node.content ?? [] 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) const line = emitInlineLine(content, 'paragraph', path)
if (!line.ok) return line if (!line.ok) return line
return success(commonMarkText(line.value)) return success(commonMarkText(line.value))
} }
function emitRule(node: AdfNode): Result<EmittedBlock> | undefined { function emitRule(node: AdfNode): Result<EmittedBlock> | undefined {
if (!commonMarkHead(node, []) || (node.content ?? []).length > 0) return undefined if (!carriesOnly(node, []) || (node.content ?? []).length > 0) return undefined
return success(commonMarkText('---')) 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))
}
+2 -1
View File
@@ -93,7 +93,8 @@ export function blockDirective(type: string): BlockDirective | undefined {
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined { export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined {
const argument = spellArgument(node, directive) const argument = spellArgument(node, directive)
if (argument === undefined) return undefined 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 if (pairs === undefined) return undefined
const marks = node.marks ?? [] const marks = node.marks ?? []
if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))]) if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))])
+1 -5
View File
@@ -11,11 +11,7 @@ export function isBareToken(text: string): boolean {
return bareToken.test(text) return bareToken.test(text)
} }
export function vocabularyPairs( export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, spelledElsewhere: readonly string[]): [string, string][] | undefined {
attrs: AdfAttributes,
vocabulary: AttributeVocabulary,
spelledElsewhere: readonly (string | undefined)[],
): [string, string][] | undefined {
const pairs: [string, string][] = [] const pairs: [string, string][] = []
for (const [key, value] of Object.entries(attrs)) { for (const [key, value] of Object.entries(attrs)) {
if (spelledElsewhere.includes(key)) continue if (spelledElsewhere.includes(key)) continue
+1 -1
View File
@@ -53,7 +53,7 @@ export function markSpelling(type: string): MarkSpelling | undefined {
} }
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | 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) return pairs === undefined ? undefined : spellAttributes(pairs)
} }
+4 -3
View File
@@ -1,4 +1,5 @@
import type { AdfNode } from './adf-document.ts' import type { AdfNode } from './adf-document.ts'
import { carriesOnly } from './adf-document.ts'
import type { ConvertErrorPath } from './result.ts' import type { ConvertErrorPath } from './result.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { serializeCanonicalJson } from './canonical-json.ts'
import { tryImageLine } from './markdown-inline.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 { function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
const content = node.content ?? [] const content = node.content ?? []
const media = content[0] const media = content[0]
if (serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle || (node.marks ?? []).length > 0) return undefined if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle) return undefined
if (media === undefined || content.length !== 1 || media.type !== 'media' || (media.marks ?? []).length > 0 || (media.content ?? []).length > 0) 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 attrs = media.attrs ?? {}
const alt = attrs['alt'] const alt = attrs['alt']
const url = attrs['url'] 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 if (alt !== undefined && (typeof alt !== 'string' || alt === '')) return undefined
return { alt, url } return { alt, url }
} }
+3 -4
View File
@@ -187,7 +187,7 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
const types = (node.marks ?? []).map((mark) => mark.type) 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) 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) 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) if (node.type === 'hardBreak') return emitHardBreak(node, directive, context, index, path)
return emitInlineDirective(node, directive, 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}`)] }) return success({ segments: [syntax(`:${node.type}[`), ...content, syntax(`]${attributes}`)] })
} }
function emitText(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result<Emission> { function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
const unspelled = Object.keys(node.attrs ?? {})[0] if (Object.keys(node.attrs ?? {}).length > 0) return success({ carry: { first: index, last: index } })
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) 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 ((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) if (/\r/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a carriage return CommonMark rewrites', path)
+5 -8
View File
@@ -1,4 +1,5 @@
import type { AdfNode } from './adf-document.ts' import type { AdfNode } from './adf-document.ts'
import { carriesOnly } from './adf-document.ts'
import { tryPipeCell } from './markdown-inline.ts' import { tryPipeCell } from './markdown-inline.ts'
import type { ConvertErrorPath } from './result.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 { function pipeRows(node: AdfNode): AdfNode[][] | undefined {
const rows = node.content ?? [] const rows = node.content ?? []
const columns = (rows[0]?.content ?? []).length const columns = (rows[0]?.content ?? []).length
if (!isPlain(node) || columns === 0) return undefined if (!carriesOnly(node, []) || columns === 0) return undefined
const grid: AdfNode[][] = [] const grid: AdfNode[][] = []
for (const [index, row] of rows.entries()) { for (const [index, row] of rows.entries()) {
const cells = row.content ?? [] 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 wanted = index === 0 ? 'tableHeader' : 'tableCell'
const paragraphs: AdfNode[] = [] const paragraphs: AdfNode[] = []
for (const cell of cells) { for (const cell of cells) {
const paragraph = plainParagraph(cell) 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) paragraphs.push(paragraph)
} }
grid.push(paragraphs) grid.push(paragraphs)
@@ -40,13 +41,9 @@ function pipeRows(node: AdfNode): AdfNode[][] | undefined {
return grid 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 { function plainParagraph(cell: AdfNode): AdfNode | undefined {
const content = cell.content ?? [] const content = cell.content ?? []
const paragraph = content[0] 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 return paragraph
} }
-1
View File
@@ -7,7 +7,6 @@ export type ConvertErrorCode =
| 'unspellable-link-title' | 'unspellable-link-title'
| 'unspellable-whitespace' | 'unspellable-whitespace'
| 'unspelled-block-separation' | 'unspelled-block-separation'
| 'unspelled-node-attribute'
| 'unsupported-document-version' | 'unsupported-document-version'
| 'unsupported-node-shape' | 'unsupported-node-shape'
+3 -1
View File
@@ -65,7 +65,9 @@ detail is settled at its own milestone.
the inline carry. `commonmark-subset/` green. the inline carry. `commonmark-subset/` green.
- [x] **2b — Block nodes.** `block-nodes/` green. A nested list that cannot interrupt the block - [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 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 `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 `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 answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give