Emitter 2b: the block-node directives, the pipe table and the refusal corpus #11

Merged
lilleman merged 6 commits from block-nodes into main 2026-08-25 16:20:31 +02:00
30 changed files with 715 additions and 56 deletions
Showing only changes of commit 56072d36ef - Show all commits
+2 -1
View File
@@ -3,7 +3,8 @@
Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and
an HTML dialect. an HTML dialect.
**Status: pre-release — `adfToMarkdown` emits the CommonMark subset, nothing else is built.** **Status: pre-release — `adfToMarkdown` emits the CommonMark subset and the block nodes, nothing
else is built.**
Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar: Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar:
[`spec/flavour.md`](spec/flavour.md). [`spec/flavour.md`](spec/flavour.md).
+4 -1
View File
@@ -8,7 +8,10 @@ in_node() { docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD:/app" -
in_node npm ci in_node npm ci
in_node npm run typecheck in_node npm run typecheck
test_output=$(in_node npm test) if ! test_output=$(in_node npm test 2>&1); then
printf '%s\n' "$test_output"
exit 1
fi
printf '%s\n' "$test_output" printf '%s\n' "$test_output"
if printf '%s' "$test_output" | grep -q ' tests 0'; then if printf '%s' "$test_output" | grep -q ' tests 0'; then
echo 'the gate ran zero tests — failing instead of a vacuous green' echo 'the gate ran zero tests — failing instead of a vacuous green'
+3
View File
@@ -9,6 +9,9 @@ One directory per contract kind:
`markdownToAdf` must build from it. One-way; the markdown is not canonical. `markdownToAdf` must build from it. One-way; the markdown is not canonical.
- `errors/``<name>.md`: markdown input that must not convert. A `<name>.error` beside it - `errors/``<name>.md`: markdown input that must not convert. A `<name>.error` beside it
pins which error. pins which error.
- `unspellable/``<name>.json`: ADF `adfToMarkdown` must refuse, the `ConvertErrorCode` in the
`<name>.error` beside it. A maintainer decision (`todo.md`) moves a document from here to
`round-trip/`.
- `real-payloads/``<name>.json`: sanitized live ADF, round-tripped ADF→markdown→ADF. No - `real-payloads/``<name>.json`: sanitized live ADF, round-tripped ADF→markdown→ADF. No
expected markdown. expected markdown.
+1
View File
@@ -0,0 +1 @@
unspelled-node-attribute
+18
View File
@@ -0,0 +1,18 @@
{
"content": [
{
"attrs": {
"localId": "01a03d5c-9b21-73f4-8e6a-0c47b1d9e2f8"
},
"content": [
{
"text": "Every block node in the schema carries one.",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
ambiguous-empty-code-block-language
@@ -0,0 +1,18 @@
{
"content": [
{
"attrs": {
"language": ""
},
"content": [
{
"text": "cargo build --release",
"type": "text"
}
],
"type": "codeBlock"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
unspelled-block-separation
@@ -0,0 +1,40 @@
{
"content": [
{
"attrs": {
"title": "Full build log"
},
"content": [
{
"content": [
{
"text": "The build ran for 11 minutes.",
"type": "text"
}
],
"type": "paragraph"
},
{
"attrs": {
"panelType": "warning"
},
"content": [
{
"content": [
{
"text": "Three warnings went unread.",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "panel"
}
],
"type": "expand"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
unspellable-link-destination
@@ -0,0 +1,23 @@
{
"content": [
{
"content": [
{
"marks": [
{
"attrs": {
"href": "https://example.com/a)b"
},
"type": "link"
}
],
"text": "The torque table",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
unspellable-link-title
+24
View File
@@ -0,0 +1,24 @@
{
"content": [
{
"content": [
{
"marks": [
{
"attrs": {
"href": "https://example.com/torque",
"title": "He said \"hi\""
},
"type": "link"
}
],
"text": "The torque table",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
+1
View File
@@ -0,0 +1 @@
ambiguous-empty-media-alt
+22
View File
@@ -0,0 +1,22 @@
{
"content": [
{
"attrs": {
"layout": "center"
},
"content": [
{
"attrs": {
"alt": "",
"type": "external",
"url": "https://example.com/moon.png"
},
"type": "media"
}
],
"type": "mediaSingle"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
unspellable-line-start
+47
View File
@@ -0,0 +1,47 @@
{
"content": [
{
"content": [
{
"content": [
{
"content": [
{
"text": "Torque the bolts",
"type": "text"
}
],
"type": "paragraph"
},
{
"attrs": {
"order": 2
},
"content": [
{
"content": [
{
"content": [
{
"text": "Then sign off",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "orderedList"
}
],
"type": "listItem"
}
],
"type": "bulletList"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
ambiguous-ordered-list-start
@@ -0,0 +1,28 @@
{
"content": [
{
"attrs": {
"order": 1
},
"content": [
{
"content": [
{
"content": [
{
"text": "Loosen the clamp",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "orderedList"
}
],
"type": "doc",
"version": 1
}
+1 -1
View File
@@ -13,7 +13,7 @@
"node": ">=24" "node": ">=24"
}, },
"scripts": { "scripts": {
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=93 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=94 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
+92 -2
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { AdfDocument, AdfMark, AdfNode } from './adf-document.ts' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf-document.ts'
import type { Result } from './result.ts' import type { Result } from './result.ts'
import { adfToMarkdown } from './index.ts' import { adfToMarkdown } from './index.ts'
@@ -116,7 +116,8 @@ test('refuses two adjacent lists of the same kind', () => {
}) })
test('refuses a node type the canonical form does not cover', () => { test('refuses a node type the canonical form does not cover', () => {
assert.equal(code(adfToMarkdown(document({ type: 'panel' }))), 'unsupported-node-type') assert.equal(code(adfToMarkdown(document({ type: 'blockCard' }))), 'unsupported-node-type')
assert.equal(code(adfToMarkdown(document({ type: 'toString' }))), 'unsupported-node-type')
assert.equal(code(adfToMarkdown(document(paragraph({ type: 'mention' })))), 'unsupported-node-type') assert.equal(code(adfToMarkdown(document(paragraph({ type: 'mention' })))), 'unsupported-node-type')
}) })
@@ -283,3 +284,92 @@ test('refuses a document nested deeper than the emitter carries', () => {
test('emits an empty list item without trailing whitespace', () => { test('emits an empty list item without trailing whitespace', () => {
assert.equal(markdown(adfToMarkdown(document({ content: [{ type: 'listItem' }], type: 'bulletList' }))), '-\n') assert.equal(markdown(adfToMarkdown(document({ content: [{ type: 'listItem' }], type: 'bulletList' }))), '-\n')
}) })
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({ 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('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 a block node mark in the reserved attribute', () => {
const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' })
assert.equal(markdown(adfToMarkdown(section({ type: 'breakout' }))), ':::layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n:::\n')
assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), ':::layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n:::\n')
})
test('refuses the content a directive body has no room for', () => {
assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'media' }))), 'unsupported-node-shape')
assert.equal(code(adfToMarkdown(document({ text: 'x', type: 'panel' }))), 'unsupported-node-shape')
})
test('separates two directive blocks in a container body by one line, two CommonMark blocks by a blank one', () => {
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')
assert.equal(code(adfToMarkdown(panel(paragraph(), text('a')))), 'unspelled-block-separation')
})
test('spells the image form for exactly the centered external media shape', () => {
const url = 'https://example.com/moon.png'
const single = (attrs: AdfAttributes, ...content: AdfNode[]): AdfDocument =>
document({ attrs: { layout: 'center' }, content: [{ attrs, content, type: 'media' }], type: 'mediaSingle' })
assert.equal(markdown(adfToMarkdown(single({ alt: 'The moon', type: 'external', url }))), `![The moon](${url})\n`)
assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `![](${url})\n`)
assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${url})\n`)
assert.equal(code(adfToMarkdown(single({ alt: '', type: 'external', url }))), 'ambiguous-empty-media-alt')
assert.equal(code(adfToMarkdown(single({ alt: 'a\nb', type: 'external', url }))), 'unspellable-whitespace')
assert.equal(code(adfToMarkdown(single({ alt: 'a\u0000b', type: 'external', url }))), 'unspellable-character')
assert.equal(code(adfToMarkdown(single({ type: 'external', url: 'https://example.com/a b>c' }))), 'unspellable-link-destination')
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.equal(code(adfToMarkdown(single({ type: 'external', url }, paragraph()))), 'unsupported-node-shape')
})
test('spells a mediaSingle the image form does not fit as a directive', () => {
const media: AdfNode = { attrs: { type: 'external', url: 'https://example.com/moon.png' }, type: 'media' }
const single: AdfNode = { attrs: { layout: 'center' }, content: [media], marks: [{ type: 'border' }], type: 'mediaSingle' }
assert.equal(
markdown(adfToMarkdown(document(single))),
':::mediaSingle {layout=center marks="[{\\"type\\":\\"border\\"}]"}\n::media {type=external url="https://example.com/moon.png"}\n:::\n',
)
})
test('spells a table as a pipe table only where every row and cell is plain', () => {
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
const cell = (type: string, ...content: AdfNode[]): AdfNode => ({ content, type })
const row = (...cells: AdfNode[]): AdfNode => ({ content: cells, type: 'tableRow' })
const table = (...rows: AdfNode[]): AdfDocument => document({ content: rows, type: 'table' })
const directive = (result: Result<string>): boolean => markdown(result).startsWith(':')
const header = row(cell('tableHeader', text('Part')))
assert.equal(markdown(adfToMarkdown(table(header, row(cell('tableCell', text('Bolt M8')))))), '| Part |\n| --- |\n| Bolt M8 |\n')
assert.equal(markdown(adfToMarkdown(table(row(cell('tableHeader', text('a|b')))))), '| a\\|b |\n| --- |\n')
assert.ok(directive(adfToMarkdown(table())))
assert.ok(directive(adfToMarkdown(table(row()))))
assert.ok(directive(adfToMarkdown(table({ type: 'tableRow' }))))
assert.ok(directive(adfToMarkdown(table(header, row()))))
assert.ok(directive(adfToMarkdown(table(row(cell('tableCell', text('Bolt M8')))))))
assert.ok(directive(adfToMarkdown(table(cell('tableHeader', text('Part'))))))
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader'))))))
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', text('a'), text('b')))))))
assert.equal(code(adfToMarkdown(table(row(cell('tableHeader', { attrs: { localId: 'a' }, type: 'paragraph' }))))), 'unspelled-node-attribute')
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', { attrs: { level: 1 }, type: 'heading' }))))))
assert.equal(code(adfToMarkdown(table(row(cell('tableHeader', { content: [{ text: ' a', type: 'text' }], type: 'paragraph' }))))), 'unspellable-whitespace')
})
+123 -34
View File
@@ -1,46 +1,75 @@
import type { AdfDocument, AdfNode } from './adf-document.ts' import type { AdfDocument, AdfNode } from './adf-document.ts'
import type { BlockDirective } from './block-directives.ts'
import type { JsonValue } from './json-value.ts' import type { JsonValue } from './json-value.ts'
import { emitInlineLine } from './markdown-inline.ts' import { blockDirective, spellDirectiveHeader } from './block-directives.ts'
import { emitImageLine, emitInlineLine } from './markdown-inline.ts'
import { emitPipeTable } from './markdown-tables.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts' import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts'
import { isAdfDocument } from './adf-document.ts' import { isAdfDocument } from './adf-document.ts'
import { largestNesting } from './nesting.ts' import { largestNesting } from './nesting.ts'
import { longestBacktickRun } from './backtick-runs.ts' import { longestBacktickRun } from './backtick-runs.ts'
import { serializeCanonicalJson } from './canonical-json.ts'
type BlockContainer = 'directive' | 'document' | 'list-item'
type EmittedBody = { fenceColons: number; text: string }
type EmittedBlock = EmittedBody & { node: AdfNode; path: ConvertErrorPath }
const centeredMediaSingle = '{"layout":"center"}'
const imageAttributes = ['alt', 'type', 'url']
const largestListMarker = 999999999 const largestListMarker = 999999999
const listTypes = ['bulletList', 'orderedList'] const listTypes = ['bulletList', 'orderedList']
export function adfToMarkdown(document: AdfDocument): Result<string> { export function adfToMarkdown(document: AdfDocument): Result<string> {
if (!isAdfDocument(document)) return failure('not-an-adf-document', 'the value is not an ADF document', []) if (!isAdfDocument(document)) return failure('not-an-adf-document', 'the value is not an ADF document', [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const blocks = emitBlocks(document.content ?? [], false, [], 0) const blocks = emitBlocks(document.content ?? [], 'document', [], 0)
if (!blocks.ok) return blocks if (!blocks.ok) return blocks
return success(blocks.value === '' ? '' : `${blocks.value}\n`) return success(blocks.value.text === '' ? '' : `${blocks.value.text}\n`)
} }
function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean, path: ConvertErrorPath, depth: number): Result<string> { function emitBlocks(nodes: readonly AdfNode[], container: BlockContainer, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
if (depth > largestNesting) return failure('unsupported-node-shape', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path) if (depth > largestNesting) return failure('unsupported-node-shape', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path)
let output = '' const blocks: EmittedBlock[] = []
let previous: AdfNode | undefined
for (const [index, node] of nodes.entries()) { for (const [index, node] of nodes.entries()) {
const nodePath = [...path, 'content', index] const nodePath = [...path, 'content', index]
if (previous !== undefined) {
if (listTypes.includes(node.type) && previous.type === node.type) {
return failure('unspellable-adjacent-lists', `two adjacent ${node.type} nodes read back as one list`, nodePath)
}
if (inListItem && listTypes.includes(node.type)) {
if (!interruptsParagraph(node)) {
return failure('unspellable-line-start', `a ${node.type} that cannot interrupt the block above it has no tight spelling`, nodePath)
}
output += '\n'
} else output += '\n\n'
}
const block = emitBlock(node, nodePath, depth) const block = emitBlock(node, nodePath, depth)
if (!block.ok) return block if (!block.ok) return block
output += block.value blocks.push({ ...block.value, node, path: nodePath })
previous = node
} }
return success(output) let fenceColons = 0
let text = ''
for (const [index, block] of blocks.entries()) {
const previous = blocks[index - 1]
if (previous !== undefined) {
const separation = separationBetween(previous, block, container)
if (!separation.ok) return separation
text += separation.value
}
fenceColons = Math.max(fenceColons, block.fenceColons)
text += block.text
}
return success({ fenceColons, text })
}
function separationBetween(previous: EmittedBlock, next: EmittedBlock, container: BlockContainer): Result<string> {
if (listTypes.includes(next.node.type) && previous.node.type === next.node.type) {
return failure('unspellable-adjacent-lists', `two adjacent ${next.node.type} nodes read back as one list`, next.path)
}
if (container === 'list-item' && listTypes.includes(next.node.type)) {
if (!interruptsParagraph(next.node)) {
return failure('unspellable-line-start', `a ${next.node.type} that cannot interrupt the block above it has no tight spelling`, next.path)
}
return success('\n')
}
if (container !== 'directive') return success('\n\n')
if (previous.fenceColons === 0 && next.fenceColons === 0) return success('\n\n')
if (previous.fenceColons > 0 && next.fenceColons > 0) return success('\n')
return failure(
'unspelled-block-separation',
`the canonical form leaves the separation between a ${previous.node.type} and a ${next.node.type} in a container body unspelled`,
next.path,
)
} }
function interruptsParagraph(node: AdfNode): boolean { function interruptsParagraph(node: AdfNode): boolean {
@@ -48,26 +77,86 @@ function interruptsParagraph(node: AdfNode): boolean {
return ((node.content ?? [])[0]?.content ?? []).length > 0 return ((node.content ?? [])[0]?.content ?? []).length > 0
} }
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> { function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
if (node.type === 'blockquote') return emitBlockquote(node, path, depth) if (node.type === 'blockquote') return plainBlock(emitBlockquote(node, path, depth))
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth) if (node.type === 'bulletList' || node.type === 'orderedList') return plainBlock(emitList(node, path, depth))
if (node.type === 'codeBlock') return emitCodeBlock(node, path) if (node.type === 'codeBlock') return plainBlock(emitCodeBlock(node, path))
if (node.type === 'heading') return emitHeading(node, path) if (node.type === 'heading') return plainBlock(emitHeading(node, path))
if (node.type === 'paragraph') return emitParagraph(node, path) if (node.type === 'paragraph') return emitParagraph(node, path)
if (node.type === 'rule') return emitRule(node, path) if (node.type === 'rule') return plainBlock(emitRule(node, path))
const directive = blockDirective(node.type)
if (directive !== undefined) {
if (node.type === 'mediaSingle') return emitMediaSingle(node, directive, path, depth)
if (node.type === 'table') return emitTable(node, directive, path, depth)
return emitDirectiveBlock(node, directive, path, depth)
}
if (node.type === 'hardBreak' || node.type === 'listItem' || node.type === 'text') { if (node.type === 'hardBreak' || node.type === 'listItem' || node.type === 'text') {
return failure('unsupported-node-shape', `a ${node.type} node cannot stand where a block belongs`, path) return failure('unsupported-node-shape', `a ${node.type} node cannot stand where a block belongs`, path)
} }
return failure('unsupported-node-type', `the canonical form spells no block node of type ${node.type}`, path) return failure('unsupported-node-type', `the canonical form spells no block node of type ${node.type}`, path)
} }
function plainBlock(text: Result<string>): Result<EmittedBody> {
if (!text.ok) return text
return success({ fenceColons: 0, text: text.value })
}
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
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 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, text: `::${header.value}` })
}
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, text: `${fence}${header.value}\n${lines}${fence}` })
}
function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Result<EmittedBody> {
if (content.length === 0) return success({ fenceColons: 0, text: '' })
return plainBlock(emitInlineLine(content, 'paragraph', path))
}
function emitMediaSingle(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
const image = imageShape(node)
if (image === undefined) return emitDirectiveBlock(node, directive, path, depth)
const mediaPath = [...path, 'content', 0]
if (image.alt === '') return failure('ambiguous-empty-media-alt', 'an empty media alt and an absent one share one image spelling', mediaPath)
return plainBlock(emitImageLine(image.alt, image.url, mediaPath))
}
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
const attrs = media.attrs ?? {}
const alt = attrs['alt']
const url = attrs['url']
if (Object.keys(attrs).some((key) => !imageAttributes.includes(key)) || attrs['type'] !== 'external') return undefined
if (typeof url !== 'string' || (alt !== undefined && typeof alt !== 'string')) return undefined
return { alt, url }
}
function emitTable(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
const pipe = emitPipeTable(node, path)
if (pipe === undefined) return emitDirectiveBlock(node, directive, path, depth)
return plainBlock(pipe)
}
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> { function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
const validation = validateBlockNode(node, [], path) const validation = validateBlockNode(node, [], path)
if (!validation.ok) return validation if (!validation.ok) return validation
const inner = emitBlocks(node.content ?? [], false, path, depth + 1) const inner = emitBlocks(node.content ?? [], 'document', path, depth + 1)
if (!inner.ok) return inner if (!inner.ok) return inner
return success( return success(
inner.value inner.value.text
.split('\n') .split('\n')
.map((line) => (line === '' ? '>' : `> ${line}`)) .map((line) => (line === '' ? '>' : `> ${line}`))
.join('\n'), .join('\n'),
@@ -152,11 +241,11 @@ function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<string> { function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<string> {
const validation = validateBlockNode(item, [], path) const validation = validateBlockNode(item, [], path)
if (!validation.ok) return validation if (!validation.ok) return validation
const inner = emitBlocks(item.content ?? [], true, 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 === '') return success(marker.trimEnd()) if (inner.value.text === '') return success(marker.trimEnd())
const indent = ' '.repeat(marker.length) const indent = ' '.repeat(marker.length)
const lines = inner.value.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] ?? '' const first = lines[0] ?? ''
if (isThematicBreak(first)) { if (isThematicBreak(first)) {
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(first)}`, path) return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(first)}`, path)
@@ -164,12 +253,12 @@ function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, dep
return success(lines.join('\n')) return success(lines.join('\n'))
} }
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<string> { function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBody> {
const validation = validateBlockNode(node, [], path) const validation = validateBlockNode(node, [], path)
if (!validation.ok) return validation if (!validation.ok) return validation
const content = node.content ?? [] const content = node.content ?? []
if (content.length === 0) return success('::paragraph') if (content.length === 0) return success({ fenceColons: 2, text: '::paragraph' })
return emitInlineLine(content, 'paragraph', path) return plainBlock(emitInlineLine(content, 'paragraph', path))
} }
function emitRule(node: AdfNode, path: ConvertErrorPath): Result<string> { function emitRule(node: AdfNode, path: ConvertErrorPath): Result<string> {
+116
View File
@@ -0,0 +1,116 @@
import type { AdfMark, AdfNode } from './adf-document.ts'
import type { AttributeKind } from './directive-attributes.ts'
import type { JsonValue } from './json-value.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { isBareToken, spellAttributeValue, spellAttributes, spellJsonAttribute } from './directive-attributes.ts'
export type BlockDirective = {
argument?: string
attributes: Readonly<Record<string, AttributeKind>>
body: 'block' | 'inline' | 'none'
}
const cellAttributes: Readonly<Record<string, AttributeKind>> = {
background: 'string',
colspan: 'number',
colwidth: 'json',
localId: 'string',
rowspan: 'number',
valign: 'string',
}
const expandAttributes: Readonly<Record<string, AttributeKind>> = { localId: 'string', title: 'string' }
const extensionAttributes: Readonly<Record<string, AttributeKind>> = {
extensionKey: 'string',
extensionType: 'string',
layout: 'string',
localId: 'string',
parameters: 'json',
text: 'string',
}
const itemAttributes: Readonly<Record<string, AttributeKind>> = { localId: 'string' }
const mediaAttributes: Readonly<Record<string, AttributeKind>> = {
alt: 'string',
collection: 'string',
height: 'number',
id: 'string',
localId: 'string',
occurrenceKey: 'string',
type: 'string',
url: 'string',
width: 'number',
}
const syncBlockAttributes: Readonly<Record<string, AttributeKind>> = { localId: 'string', resourceId: 'string' }
const blockDirectives: Readonly<Record<string, BlockDirective>> = {
blockTaskItem: { argument: 'state', attributes: itemAttributes, body: 'block' },
bodiedExtension: { attributes: extensionAttributes, body: 'block' },
bodiedSyncBlock: { attributes: syncBlockAttributes, body: 'block' },
caption: { attributes: itemAttributes, body: 'inline' },
decisionItem: { attributes: { localId: 'string', state: 'string' }, body: 'inline' },
decisionList: { attributes: itemAttributes, body: 'block' },
expand: { attributes: expandAttributes, body: 'block' },
extension: { attributes: extensionAttributes, body: 'none' },
extensionFrame: { attributes: {}, body: 'block' },
layoutColumn: { attributes: { localId: 'string', valign: 'string', width: 'number' }, body: 'block' },
layoutSection: { attributes: itemAttributes, body: 'block' },
media: { attributes: mediaAttributes, body: 'none' },
mediaGroup: { attributes: {}, body: 'block' },
mediaSingle: { attributes: { layout: 'string', localId: 'string', width: 'number', widthType: 'string' }, body: 'block' },
multiBodiedExtension: { attributes: extensionAttributes, body: 'block' },
nestedExpand: { attributes: expandAttributes, body: 'block' },
panel: {
argument: 'panelType',
attributes: { localId: 'string', panelColor: 'string', panelIcon: 'string', panelIconId: 'string', panelIconText: 'string' },
body: 'block',
},
syncBlock: { attributes: syncBlockAttributes, body: 'none' },
table: { attributes: { displayMode: 'string', isNumberColumnEnabled: 'boolean', layout: 'string', localId: 'string', width: 'number' }, body: 'block' },
tableCell: { attributes: cellAttributes, body: 'block' },
tableHeader: { attributes: cellAttributes, body: 'block' },
tableRow: { attributes: itemAttributes, body: 'block' },
taskItem: { argument: 'state', attributes: itemAttributes, body: 'inline' },
taskList: { attributes: itemAttributes, body: 'block' },
}
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<string> {
const pairs: [string, string][] = []
let argument = ''
for (const [key, value] of Object.entries(node.attrs ?? {})) {
if (key === directive.argument) {
if (typeof value !== 'string' || !isBareToken(value)) {
return failure('unspelled-node-attribute', `the ${node.type} attribute ${key} holds no bare token the arg slot spells`, path)
}
argument = ` ${value}`
continue
}
const kind = attributeKind(directive, key)
if (kind === undefined) return failure('unspelled-node-attribute', `the ${node.type} attribute ${key} has no canonical markdown spelling`, path)
const spelled = spellAttributeValue(value, kind)
if (spelled === undefined) return failure('unsupported-node-shape', `the ${node.type} attribute ${key} holds no ${kind}`, path)
pairs.push([key, spelled])
}
const marks = node.marks ?? []
if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))])
const attributes = spellAttributes(pairs)
return success(`${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}`)
}
function attributeKind(directive: BlockDirective, key: string): AttributeKind | undefined {
return Object.hasOwn(directive.attributes, key) ? directive.attributes[key] : undefined
}
function markValues(marks: readonly AdfMark[]): JsonValue {
return marks.map((mark) => {
const attrs = mark.attrs ?? {}
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
})
}
+26 -3
View File
@@ -11,9 +11,10 @@ import { serializeCanonicalJson } from './canonical-json.ts'
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus') const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus')
const roundTripRoot = join(corpusRoot, 'round-trip') const roundTripRoot = join(corpusRoot, 'round-trip')
const unspellableRoot = join(corpusRoot, 'unspellable')
const emittingDirectories = ['commonmark-subset'] const emittingDirectories = ['block-nodes', 'commonmark-subset']
const pendingDirectories = ['block-nodes', 'inline-nodes'] const pendingDirectories = ['inline-nodes']
function directoryNames(root: string): string[] { function directoryNames(root: string): string[] {
return readdirSync(root, { withFileTypes: true }) return readdirSync(root, { withFileTypes: true })
@@ -23,7 +24,11 @@ function directoryNames(root: string): string[] {
} }
function fixtureNames(directory: string, extension: string): string[] { function fixtureNames(directory: string, extension: string): string[] {
return readdirSync(join(roundTripRoot, directory)) return names(join(roundTripRoot, directory), extension)
}
function names(root: string, extension: string): string[] {
return readdirSync(root)
.filter((name) => name.endsWith(extension)) .filter((name) => name.endsWith(extension))
.map((name) => name.slice(0, -extension.length)) .map((name) => name.slice(0, -extension.length))
.sort() .sort()
@@ -67,6 +72,24 @@ for (const directory of emittingDirectories) {
} }
} }
test('unspellable pairs every .json with an .error', () => {
assert.deepEqual(names(unspellableRoot, '.json'), names(unspellableRoot, '.error'))
})
test('unspellable holds fixtures', () => {
assert.ok(names(unspellableRoot, '.json').length > 0)
})
for (const name of names(unspellableRoot, '.json')) {
test(`unspellable/${name} is refused with the error it names`, () => {
const parsed: unknown = JSON.parse(readFileSync(join(unspellableRoot, `${name}.json`), 'utf8'))
assert.ok(isAdfDocument(parsed), `${name}.json is not an ADF document`)
const result = adfToMarkdown(parsed)
assert.ok(!result.ok, result.ok ? `emitted ${JSON.stringify(result.value)}` : '')
assert.equal(result.error.code, readFileSync(join(unspellableRoot, `${name}.error`), 'utf8').trimEnd())
})
}
test('the corpus holds JSON to gate', () => { test('the corpus holds JSON to gate', () => {
assert.ok(corpusJsonPaths().length > 0) assert.ok(corpusJsonPaths().length > 0)
}) })
+35
View File
@@ -0,0 +1,35 @@
import type { JsonValue } from './json-value.ts'
import { serializeCanonicalJson } from './canonical-json.ts'
export type AttributeKind = 'boolean' | 'json' | 'number' | 'string'
const bareToken = /^[A-Za-z0-9_-]+$/
export function isBareToken(text: string): boolean {
return bareToken.test(text)
}
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
if (pairs.length === 0) return ''
const spelled = [...pairs].sort(([left], [right]) => (left < right ? -1 : 1)).map(([key, value]) => `${key}=${value}`)
return `{${spelled.join(' ')}}`
}
export function spellAttributeValue(value: JsonValue, kind: AttributeKind): string | undefined {
if (kind === 'json') return spellJsonAttribute(value)
if (kind === 'boolean') return typeof value === 'boolean' ? `${value}` : undefined
if (kind === 'number') return typeof value === 'number' ? spell(JSON.stringify(value)) : undefined
return typeof value === 'string' ? spell(value) : undefined
}
export function spellJsonAttribute(value: JsonValue): string {
return quote(serializeCanonicalJson(value, 'compact'))
}
function spell(text: string): string {
return isBareToken(text) ? text : quote(text)
}
function quote(text: string): string {
return JSON.stringify(text)
}
+6 -4
View File
@@ -6,7 +6,7 @@ export type InlineSegment =
export type AssembledLine = { line: string; unspellableMark: string | undefined } export type AssembledLine = { line: string; unspellableMark: string | undefined }
export type LineContainer = 'heading' | 'paragraph' 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; closeMark: string | undefined; end: number; openMark: string | undefined; start: number }
@@ -127,8 +127,9 @@ function isSyntax(kind: InlineSegment['kind'] | undefined): boolean {
} }
function opensConstruct(scan: string, index: number, inLinkText: boolean, container: LineContainer, escaped: ReadonlySet<number>): boolean { function opensConstruct(scan: string, index: number, inLinkText: boolean, container: LineContainer, escaped: ReadonlySet<number>): boolean {
const claimsLine = container === 'heading' ? closesHeading(scan, index) : claimsLineStart(scan, index) if (container === 'heading' && closesHeading(scan, index)) return true
return claimsLine || claimsCharacter(scan, index, inLinkText, escaped) if (container === 'paragraph' && claimsLineStart(scan, index)) return true
return claimsCharacter(scan, index, inLinkText, container, escaped)
} }
function claimsLineStart(scan: string, index: number): boolean { function claimsLineStart(scan: string, index: number): boolean {
@@ -144,10 +145,11 @@ function closesHeading(scan: string, index: number): boolean {
return index === 0 || /[ \t]/.test(scan.charAt(index - 1)) return index === 0 || /[ \t]/.test(scan.charAt(index - 1))
} }
function claimsCharacter(scan: string, index: number, inLinkText: boolean, escaped: ReadonlySet<number>): boolean { function claimsCharacter(scan: string, index: number, inLinkText: boolean, container: LineContainer, escaped: ReadonlySet<number>): boolean {
const character = scan.charAt(index) const character = scan.charAt(index)
const rest = scan.slice(index) const rest = scan.slice(index)
if (inLinkText && (character === '[' || character === ']')) return true if (inLinkText && (character === '[' || character === ']')) return true
if (character === '|') return container === 'table-cell'
if (character === '\\') return asciiPunctuation.test(scan.charAt(index + 1)) if (character === '\\') return asciiPunctuation.test(scan.charAt(index + 1))
if (character === '&') return startsEntityReference(rest) if (character === '&') return startsEntityReference(rest)
if (character === '<') return opensBracketedAutolink(rest) || htmlConstructs.some((construct) => construct.test(rest)) if (character === '<') return opensBracketedAutolink(rest) || htmlConstructs.some((construct) => construct.test(rest))
+16 -3
View File
@@ -20,7 +20,20 @@ const linkAttributes = ['href', 'title']
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> { export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, container, inLinkText: false, path }) const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, container, inLinkText: false, path })
if (!segments.ok) return segments if (!segments.ok) return segments
const assembled = assembleInlineLine(segments.value, container) return finishLine(segments.value, container, path)
}
export function emitImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): Result<string> {
if (alt !== undefined && /[\n\r]/.test(alt)) return failure('unspellable-whitespace', 'a media alt holds a newline no image description spells', path)
if (alt !== undefined && holdsNullCharacter(alt)) return failure('unspellable-character', 'a media alt holds a null character CommonMark replaces', path)
const destination = spellDestination(href, path)
if (!destination.ok) return destination
const description: InlineSegment[] = alt === undefined ? [] : [{ kind: 'link-text', text: alt }]
return finishLine([{ kind: 'syntax', text: '![' }, ...description, { kind: 'syntax', text: `](${destination.value})` }], 'paragraph', path)
}
function finishLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<string> {
const assembled = assembleInlineLine(segments, container)
if (assembled.unspellableMark !== undefined) { if (assembled.unspellableMark !== undefined) {
return failure('unspellable-mark', `the ${assembled.unspellableMark} spelling cannot open or close where it sits`, path) return failure('unspellable-mark', `the ${assembled.unspellableMark} spelling cannot open or close where it sits`, path)
} }
@@ -83,8 +96,8 @@ 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)
if (node.type === 'hardBreak') { if (node.type === 'hardBreak') {
if (context.container === 'heading' || context.atBlockEnd) return success([{ kind: 'syntax', text: ':hardBreak{}' }]) if (context.container === 'paragraph' && !context.atBlockEnd) return success([{ kind: 'syntax', text: '\\\n' }])
return success([{ kind: 'syntax', text: '\\\n' }]) return success([{ kind: 'syntax', text: ':hardBreak{}' }])
} }
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node carries no text', path) if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node carries no text', path)
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node carries content', path) if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node carries content', path)
+53
View File
@@ -0,0 +1,53 @@
import type { AdfNode } from './adf-document.ts'
import { emitInlineLine } from './markdown-inline.ts'
import { success, type ConvertErrorPath, type Result } from './result.ts'
export function emitPipeTable(node: AdfNode, path: ConvertErrorPath): Result<string> | undefined {
const rows = pipeRows(node)
if (rows === undefined) return undefined
const lines: string[] = []
for (const [rowIndex, row] of rows.entries()) {
const cells: string[] = []
for (const [cellIndex, paragraph] of row.entries()) {
const content = paragraph.content ?? []
const line = content.length === 0 ? success('') : emitInlineLine(content, 'table-cell', [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
if (!line.ok) return line
cells.push(line.value)
}
lines.push(`| ${cells.join(' | ')} |`)
if (rowIndex === 0) lines.push(`| ${cells.map(() => '---').join(' | ')} |`)
}
return success(lines.join('\n'))
}
function pipeRows(node: AdfNode): AdfNode[][] | undefined {
const rows = node.content ?? []
const columns = (rows[0]?.content ?? []).length
if (!isPlain(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
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
paragraphs.push(paragraph)
}
grid.push(paragraphs)
}
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
const pipedCode = (paragraph.content ?? []).some((child) => (child.marks ?? []).some((mark) => mark.type === 'code') && (child.text ?? '').includes('|'))
return pipedCode ? undefined : paragraph
}
+2
View File
@@ -1,5 +1,6 @@
export type ConvertErrorCode = export type ConvertErrorCode =
| 'ambiguous-empty-code-block-language' | 'ambiguous-empty-code-block-language'
| 'ambiguous-empty-media-alt'
| 'ambiguous-ordered-list-start' | 'ambiguous-ordered-list-start'
| 'not-an-adf-document' | 'not-an-adf-document'
| 'reserved-adf-language' | 'reserved-adf-language'
@@ -13,6 +14,7 @@ export type ConvertErrorCode =
| 'unspellable-mark' | 'unspellable-mark'
| 'unspellable-whitespace' | 'unspellable-whitespace'
| 'unspelled-block-marks' | 'unspelled-block-marks'
| 'unspelled-block-separation'
| 'unspelled-node-attribute' | 'unspelled-node-attribute'
| 'unsupported-document-version' | 'unsupported-document-version'
| 'unsupported-heading-level' | 'unsupported-heading-level'
+8 -7
View File
@@ -28,12 +28,12 @@ detail is settled at its own milestone.
carries a `localId` with no spelling, `codeBlock` also `hideLineNumbers`, `uniqueId` and carries a `localId` with no spelling, `codeBlock` also `hideLineNumbers`, `uniqueId` and
`wrap`, `blockquote` also marks, and `hardBreak` `text` and `localId` with no section for `wrap`, `blockquote` also marks, and `hardBreak` `text` and `localId` with no section for
the carry fallback to reach. Picking one (directive sections for those nodes, or the opaque the carry fallback to reach. Picking one (directive sections for those nodes, or the opaque
carry) is a permanent format decision (§8). Three collision sites are held out of the corpus carry) is a permanent format decision (§8). Three collision sites wait in
meanwhile, each a choice between the absent attribute and the empty value: a `codeBlock` `corpus/unspellable/` meanwhile, each a choice between the absent attribute and the empty
whose info string is empty and `media` with an empty `alt`, which one "exactly that shape" value: a `codeBlock` whose info string is empty and `media` with an empty `alt`, which one
rule — as the CommonMark image already uses — could settle together, and an `orderedList` "exactly that shape" rule — as the CommonMark image already uses — could settle together,
starting at 1, independent of the totality answer since `order: 9` keeps the markdown form and an `orderedList` starting at 1, independent of the totality answer since `order: 9`
either way. **Also blocked**: the link rule covers destination spaces only, so two shapes keeps the markdown form either way. **Also blocked**: the link rule covers destination spaces only, so two shapes
break §2 silently — href `https://example.com/a)b` emits `[t](https://example.com/a)b)`, break §2 silently — href `https://example.com/a)b` emits `[t](https://example.com/a)b)`,
read back as href `…/a` plus literal `b)`; title `He said "hi"` emits read back as href `…/a` plus literal `b)`; title `He said "hi"` emits
`[t](u "He said "hi"")`, which holds no title. Two defensible spellings each — angle `[t](u "He said "hi"")`, which holds no title. Two defensible spellings each — angle
@@ -42,7 +42,8 @@ detail is settled at its own milestone.
directive block in a container body — an `expand` whose content is `paragraph` "A" then a directive block in a container body — an `expand` whose content is `paragraph` "A" then a
`panel` (`panelType` `warning`) holding "B" spells `A` and `:::panel warning` either on `panel` (`panelType` `warning`) holding "B" spells `A` and `:::panel warning` either on
consecutive lines or with a blank line between. Two defensible spellings, so §8 leaves the consecutive lines or with a blank line between. Two defensible spellings, so §8 leaves the
pick here. pick here; `unspelled-block-separation` refuses the pair meanwhile, an empty paragraph's
`::paragraph` beside a CommonMark block included.
- [x] **1d1 — The CommonMark subset**: blockquote, bulletList, codeBlock, heading, orderedList, - [x] **1d1 — The CommonMark subset**: blockquote, bulletList, codeBlock, heading, orderedList,
paragraph, rule, listItem, hardBreak, text, code spans, and the `code`, `em`, `link`, paragraph, rule, listItem, hardBreak, text, code spans, and the `code`, `em`, `link`,
`strike` and `strong` marks — one mark per text node; nesting is 1d3's. `strike` and `strong` marks — one mark per text node; nesting is 1d3's.