Give the CommonMark blocks a directive form for what their spelling cannot hold #26

Merged
lilleman merged 5 commits from tick-2f into main 2026-08-27 16:08:32 +02:00
30 changed files with 639 additions and 210 deletions
Showing only changes of commit 59b42ef0dc - Show all commits
@@ -0,0 +1,3 @@
:::paragraph {localId=01a03d5c-9b21-73f4-8e6a-0c47b1d9e2f8}
Every block node in the schema carries one.
:::
@@ -0,0 +1,13 @@
:::codeBlock {language=adf}
```
{
"type": "blockCard"
}
```
:::
:::codeBlock {language=adf}
````
```
````
:::
@@ -0,0 +1,58 @@
{
"content": [
{
"attrs": {
"hideLineNumbers": true,
"language": "rust",
"localId": "01a0431d-201b-70a7-81fa-8d0072e09eab",
"uniqueId": "4478e39c",
"wrap": true
},
"content": [
{
"text": "fn main() {}",
"type": "text"
}
],
"type": "codeBlock"
},
{
"attrs": {
"language": "a`b"
},
"content": [
{
"text": "x",
"type": "text"
}
],
"type": "codeBlock"
},
{
"attrs": {
"language": " sql"
},
"content": [
{
"text": "SELECT 1",
"type": "text"
}
],
"type": "codeBlock"
},
{
"attrs": {
"language": "adf"
},
"content": [
{
"text": "y",
"type": "text"
}
],
"type": "codeBlock"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,23 @@
:::codeBlock {hideLineNumbers=true localId=01a0431d-201b-70a7-81fa-8d0072e09eab uniqueId=4478e39c wrap=true}
```rust
fn main() {}
```
:::
:::codeBlock {language="a\u0060b"}
```
x
```
:::
:::codeBlock {language=" sql"}
```
SELECT 1
```
:::
:::codeBlock {language="\u0026#97;df"}
```
y
```
:::
@@ -0,0 +1,5 @@
:::codeBlock {language=""}
```
cargo build --release
```
:::
@@ -0,0 +1,145 @@
{
"content": [
{
"attrs": {
"level": 2,
"localId": "01a0431d-2014-737c-a1bf-f9daedab6de3"
},
"content": [
{
"text": "Release checklist",
"type": "text"
}
],
"type": "heading"
},
{
"content": [
{
"text": "Centred.",
"type": "text"
}
],
"marks": [
{
"attrs": {
"align": "center"
},
"type": "alignment"
}
],
"type": "paragraph"
},
{
"attrs": {
"localId": "01a0431d-2015-737f-afad-33064295ad4f"
},
"content": [
{
"attrs": {
"localId": "01a0431d-2016-7abb-823f-c0942934c878"
},
"content": [
{
"content": [
{
"text": "Tag the commit",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
},
{
"content": [
{
"content": [
{
"text": "Push the tag",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "bulletList"
},
{
"content": [
{
"content": [
{
"content": [
{
"text": "Loosen the clamp",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "orderedList"
},
{
"content": [
{
"attrs": {
"localId": "01a0431d-2017-72cf-baf3-8934a54e0582"
},
"content": [
{
"text": "Hold the line.",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "blockquote"
},
{
"content": [
{
"content": [
{
"attrs": {
"localId": "01a0431d-2018-7667-a769-ab0037ca80aa"
},
"content": [
{
"text": "Indented.",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "bulletList"
},
{
"attrs": {
"localId": "01a0431d-2019-76b7-bf6f-13841363b111"
},
"type": "rule"
},
{
"attrs": {
"localId": "01a0431d-201a-7a65-bea7-84b441c85bd6"
},
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,34 @@
:::heading {level=2 localId=01a0431d-2014-737c-a1bf-f9daedab6de3}
Release checklist
:::
:::paragraph {marks="[{\"attrs\":{\"align\":\"center\"},\"type\":\"alignment\"}]"}
Centred.
:::
::::bulletList {localId=01a0431d-2015-737f-afad-33064295ad4f}
:::listItem {localId=01a0431d-2016-7abb-823f-c0942934c878}
Tag the commit
:::
:::listItem
Push the tag
:::
::::
::::orderedList
:::listItem
Loosen the clamp
:::
::::
> :::paragraph {localId=01a0431d-2017-72cf-baf3-8934a54e0582}
> Hold the line.
> :::
- :::paragraph {localId=01a0431d-2018-7667-a769-ab0037ca80aa}
Indented.
:::
::rule {localId=01a0431d-2019-76b7-bf6f-13841363b111}
::paragraph {localId=01a0431d-201a-7a65-bea7-84b441c85bd6}
@@ -0,0 +1,77 @@
{
"content": [
{
"attrs": {
"level": 7
},
"content": [
{
"text": "Seventh",
"type": "text"
}
],
"type": "heading"
},
{
"content": [
{
"text": "Levelless",
"type": "text"
}
],
"type": "heading"
},
{
"attrs": {
"order": 999999998
},
"content": [
{
"content": [
{
"content": [
{
"text": "One",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
},
{
"content": [
{
"content": [
{
"text": "Two",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
},
{
"content": [
{
"content": [
{
"text": "Three",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "orderedList"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,19 @@
:::heading {level=7}
Seventh
:::
:::heading
Levelless
:::
::::orderedList {order=999999998}
:::listItem
One
:::
:::listItem
Two
:::
:::listItem
Three
:::
::::
@@ -0,0 +1 @@
1. Loosen the clamp
@@ -0,0 +1,44 @@
{
"content": [
{
"content": [
{
"text": "One",
"type": "text"
},
{
"attrs": {
"localId": "01a0431d-201e-7400-a175-9c04d3628f95"
},
"type": "hardBreak"
},
{
"text": "Two",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "Three",
"type": "text"
},
{
"attrs": {
"text": "\n"
},
"type": "hardBreak"
},
{
"text": "Four",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,3 @@
One:hardBreak{localId=01a0431d-201e-7400-a175-9c04d3628f95}Two
Three:hardBreak{text="\n"}Four
@@ -1,29 +0,0 @@
```adf
{
"attrs": {
"language": "adf"
},
"content": [
{
"text": "{\n \"type\": \"blockCard\"\n}",
"type": "text"
}
],
"type": "codeBlock"
}
```
````adf
{
"attrs": {
"language": "adf"
},
"content": [
{
"text": "```",
"type": "text"
}
],
"type": "codeBlock"
}
````
-1
View File
@@ -1 +0,0 @@
unspelled-node-attribute
@@ -1 +0,0 @@
ambiguous-attribute-spelling
@@ -1 +0,0 @@
ambiguous-attribute-spelling
+1 -1
View File
@@ -13,7 +13,7 @@
"node": ">=24"
},
"scripts": {
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=95 --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=96 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
+40 -7
View File
@@ -16,8 +16,8 @@ normalizes to it through the round-trip.
cannot parse (intra-word). Strike is GFM strikethrough narrowed to exactly two tildes — a
single tilde or a run of three or more is literal text — and block structure resolves before
inline, so a `~~~` line opens a CommonMark tilde code fence.
- Bullet lists `- `; ordered lists incrementing `1.` `2.` `3.`, the first number taken from the
node's `order` attribute. Continuation lines align with the first character after the marker
- 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.
- Blockquotes prefix lines with `> `; a blank line inside a blockquote is a bare `>`.
@@ -31,7 +31,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 — it is `:hardBreak{}`.
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.
`<url>` autolink form only when the text equals the destination and the destination is a valid
@@ -125,8 +126,9 @@ positions canonicalize differently, each fitting where it sits:
- **Inline position**: `:adf{json="…"}` — compact serialization (keys sorted, no whitespace),
JSON-string-escaped into the attribute.
The info string `adf` is reserved: a genuine `codeBlock` whose `language` is exactly `adf` is
itself emitted through the opaque carry, so the reservation stays absolute and stays lossless.
The info string `adf` is reserved: a genuine `codeBlock` whose `language` is exactly `adf` takes
the attribute the section below keeps for a language no info string holds, so the reservation
stays absolute.
In block-directive positions (`::adf`, `:::adf`) the reserved name is a named error — the
carry's block form is the fence.
@@ -161,6 +163,36 @@ A node the sections cannot spell rides the opaque carry: an attrs key its sectio
list, a value that is not the section's type, or an arg-slot value that is no bare token. In
markdown input the same mismatch is a named error.
### The CommonMark blocks
CommonMark spells `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`, `orderedList`,
`paragraph` and `rule`, and keeps that spelling wherever it holds what the node carries. What it
cannot — `localId` (string) on any of them, marks, and the values below — takes the directive
form.
- `blockquote`, `bulletList`, `listItem` — containers, block body; no attributes beyond `localId`.
- `codeBlock` — container, body one fenced code block whose info string is the language and whose
content is the node's. Attributes: `hideLineNumbers` (boolean), `language` (string), `localId`,
`uniqueId` (string), `wrap` (boolean). A language no info string holds — empty, or holding a
backtick, edge whitespace, an entity reference or the reserved `adf` — rides the `language`
attribute instead, and the fence carries no info string.
- `heading` — container, inline body. Attributes: `level` (number), `localId`. `level` is the `#`
count, so a heading carrying none, or one outside 1-6, has no CommonMark spelling.
- `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`.
- `rule` — leaf; no attributes beyond `localId`.
````
:::codeBlock {localId=01a03d5c-9b21-73f4-8e6a-0c47b1d9e2f8 wrap=true}
```rust
fn main() {}
```
:::
````
### Panel
Container; the arg is `panelType` (`custom` `error` `info` `note` `success` `tip` `warning`).
@@ -331,8 +363,8 @@ attributes. The other three share: `extensionKey` (string), `extensionType` (str
## Inline nodes
Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of
the nodes below, those with a `text` attribute spell it in the content slot as plain text: `[]` is
the empty string, absent content is the absent attribute, non-empty content parsing to anything
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
@@ -340,6 +372,7 @@ error.
- `date` — `localId` (string), `timestamp` (string, epoch milliseconds).
- `emoji` — `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` (string).
- `hardBreak` — `localId` (string), `text` (string).
- `inlineCard` — `data` (json), `localId` (string), `url` (string); real payloads carry one or
the other.
- `mediaInline` — `alt` (string), `collection` (string), `data` (json), `height` (number), `id`
+27 -28
View File
@@ -26,7 +26,7 @@ function path(result: Result<string>): readonly (number | string)[] {
}
test('names the node a refusal came from', () => {
const unspellable: AdfNode = { attrs: { localId: 'a' }, type: 'paragraph' }
const unspellable: AdfNode = { text: 'x', type: 'paragraph' }
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' }
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0])
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { type: 'text' })))), ['content', 0, 'content', 1])
@@ -41,28 +41,31 @@ test('refuses a document version the markdown cannot carry', () => {
assert.equal(code(adfToMarkdown({ type: 'doc', version: 2 })), 'unsupported-document-version')
})
test('refuses an attribute the canonical form does not spell', () => {
assert.equal(code(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'paragraph' }))), 'unspelled-node-attribute')
assert.equal(code(adfToMarkdown(document({ attrs: { wrap: true }, type: 'codeBlock' }))), 'unspelled-node-attribute')
assert.equal(code(adfToMarkdown(document(paragraph({ attrs: { localId: 'a' }, type: 'hardBreak' }, { text: 'x', type: 'text' })))), 'unspelled-node-attribute')
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('refuses marks on a block node', () => {
assert.equal(code(adfToMarkdown(document({ marks: [{ type: 'border' }], type: 'blockquote' }))), 'unspelled-block-marks')
test('spells a CommonMark block as a directive where its own spelling holds neither attribute nor mark', () => {
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'paragraph' }))), '::paragraph {localId=a}\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { wrap: true }, type: 'codeBlock' }))), ':::codeBlock {wrap=true}\n```\n```\n:::\n')
assert.equal(markdown(adfToMarkdown(document(paragraph({ attrs: { localId: 'a' }, type: 'hardBreak' }, { text: 'x', type: 'text' })))), ':hardBreak{localId=a}x\n')
assert.equal(markdown(adfToMarkdown(document({ marks: [{ type: 'border' }], type: 'blockquote' }))), ':::blockquote {marks="[{\\"type\\":\\"border\\"}]"}\n:::\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'listItem' }))), ':::listItem\n:::\n')
})
test('refuses an ordered list whose markdown start is ambiguous', () => {
test('spells an ordered list from the order attribute its first marker is', () => {
const items: AdfNode[] = [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }]
assert.equal(code(adfToMarkdown(document({ content: items, type: 'orderedList' }))), 'ambiguous-attribute-spelling')
assert.equal(code(adfToMarkdown(document({ attrs: { order: 1 }, content: items, type: 'orderedList' }))), 'ambiguous-attribute-spelling')
assert.equal(markdown(adfToMarkdown(document({ content: items, type: 'orderedList' }))), '::::orderedList\n:::listItem\nx\n:::\n::::\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 1 }, content: items, type: 'orderedList' }))), '1. x\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: items, type: 'orderedList' }))), '2. x\n')
})
test('refuses the code block info strings the fence cannot hold', () => {
assert.equal(code(adfToMarkdown(document({ attrs: { language: '' }, type: 'codeBlock' }))), 'ambiguous-attribute-spelling')
assert.equal(code(adfToMarkdown(document({ attrs: { language: 'a`b' }, type: 'codeBlock' }))), 'unspellable-code-block-language')
assert.equal(code(adfToMarkdown(document({ attrs: { language: ' sql' }, type: 'codeBlock' }))), 'unspellable-code-block-language')
assert.equal(code(adfToMarkdown(document({ attrs: { language: '&#97;df' }, type: 'codeBlock' }))), 'unspellable-code-block-language')
test('spells a code block language no info string holds as an attribute', () => {
const language = (value: string): string => markdown(adfToMarkdown(document({ attrs: { language: value }, type: 'codeBlock' })))
assert.equal(language(''), ':::codeBlock {language=""}\n```\n```\n:::\n')
assert.equal(language('a`b'), ':::codeBlock {language="a\\u0060b"}\n```\n```\n:::\n')
assert.equal(language(' sql'), ':::codeBlock {language=" sql"}\n```\n```\n:::\n')
assert.equal(language('&#97;df'), ':::codeBlock {language="\\u0026#97;df"}\n```\n```\n:::\n')
})
test('refuses a link destination CommonMark cannot spell', () => {
@@ -131,15 +134,11 @@ test('carries a node type no section spells', () => {
assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```adf\n{\n "type": "toString"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), ':adf{json="{\\"type\\":\\"blockCard\\"}"}\n')
assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```adf\n{\n "text": "x",\n "type": "text"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'listItem' }))), '```adf\n{\n "type": "listItem"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```adf\n{\n "type": "hardBreak"\n}\n```\n')
})
test('carries the code block whose language is the reserved info string', () => {
assert.equal(
markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))),
'```adf\n{\n "attrs": {\n "language": "adf"\n },\n "type": "codeBlock"\n}\n```\n',
)
test('spells the code block whose language is the reserved info string', () => {
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), ':::codeBlock {language=adf}\n```\n```\n:::\n')
})
test('breaks a mark run at the node it carries', () => {
@@ -166,13 +165,13 @@ test('refuses a node whose content model the canonical form cannot emit', () =>
assert.equal(code(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), 'unsupported-node-shape')
})
test('refuses an ordered list no marker spells', () => {
test('spells an ordered list no marker fits as a directive', () => {
const item: AdfNode = { content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }
const list = (order: number, items: number): AdfDocument =>
document({ attrs: { order }, content: Array.from({ length: items }, () => item), type: 'orderedList' })
assert.equal(code(adfToMarkdown(list(1.5, 1))), 'unsupported-node-shape')
assert.equal(markdown(adfToMarkdown(list(1.5, 1))), '::::orderedList {order="1.5"}\n:::listItem\nx\n:::\n::::\n')
assert.equal(markdown(adfToMarkdown(list(999999999, 1))), '999999999. x\n')
assert.equal(code(adfToMarkdown(list(999999999, 2))), 'unspellable-list-marker')
assert.equal(markdown(adfToMarkdown(list(999999999, 2))), '::::orderedList {order=999999999}\n:::listItem\nx\n:::\n:::listItem\nx\n:::\n::::\n')
})
test('carries a code mark over anything but text', () => {
@@ -182,9 +181,9 @@ test('carries a code mark over anything but text', () => {
)
})
test('refuses a heading level outside the ATX range', () => {
assert.equal(code(adfToMarkdown(document({ attrs: { level: 7 }, content: [{ text: 'x', type: 'text' }], type: 'heading' }))), 'unsupported-heading-level')
assert.equal(code(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'heading' }))), 'unsupported-heading-level')
test('spells a heading level no ATX heading fits as a directive', () => {
assert.equal(markdown(adfToMarkdown(document({ attrs: { level: 7 }, content: [{ text: 'x', type: 'text' }], type: 'heading' }))), ':::heading {level=7}\nx\n:::\n')
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'heading' }))), ':::heading\nx\n:::\n')
})
test('escapes only text that would otherwise open a construct', () => {
@@ -480,7 +479,7 @@ test('spells a table as a pipe table only where every row and cell is plain', ()
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: { localId: 'a' }, type: 'paragraph' }))))))
assert.equal(
markdown(adfToMarkdown(table(row(cell('tableHeader', { content: [{ attrs: { url: 'a|b' }, type: 'blockCard' }], type: 'paragraph' }))))),
'| :adf{json="{\\"attrs\\":{\\"url\\":\\"a\\u007cb\\"},\\"type\\":\\"blockCard\\"}"} |\n| --- |\n',
+92 -113
View File
@@ -54,17 +54,19 @@ function emitBlocks(nodes: readonly AdfNode[], container: BlockContainer, path:
}
function separationBetween(previous: PlacedBlock, next: PlacedBlock, 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)
const plainPair = previous.spelling === 'commonmark' && next.spelling === 'commonmark'
if (plainPair && listTypes.includes(next.node.type)) {
if (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') {
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')
}
return success('\n')
}
if (container !== 'directive') return success('\n\n')
if (previous.spelling === 'commonmark' && next.spelling === 'commonmark') return success('\n\n')
if (container !== 'directive' || plainPair) return success('\n\n')
if (previous.spelling === 'directive' && next.spelling === 'directive') return success('\n')
return failure(
'unspelled-block-separation',
@@ -79,19 +81,27 @@ function interruptsParagraph(node: AdfNode): boolean {
}
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
if (node.type === 'blockquote') return commonMarkContainer(emitBlockquote(node, path, depth))
if (node.type === 'bulletList' || node.type === 'orderedList') return commonMarkContainer(emitList(node, path, depth))
if (node.type === 'codeBlock') return commonMarkLine(emitCodeBlock(node, path))
if (node.type === 'heading') return commonMarkLine(emitHeading(node, path))
if (node.type === 'paragraph') return emitParagraph(node, path)
if (node.type === 'rule') return commonMarkLine(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)
}
return commonMarkLine(carriedBlock(node, path))
if (directive === undefined) return commonMarkLine(carriedBlock(node, path))
const readable = readableBlock(node, path, depth)
if (readable !== undefined) return readable
return emitDirectiveBlock(node, directive, path, depth)
}
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
if (node.type === 'blockquote') return emitBlockquote(node, path, depth)
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth)
if (node.type === 'codeBlock') return emitCodeBlock(node, path)
if (node.type === 'heading') return emitHeading(node, path)
if (node.type === 'mediaSingle') return readableText(tryImage(node, path))
if (node.type === 'paragraph') return emitParagraph(node, path)
if (node.type === 'rule') return emitRule(node)
if (node.type === 'table') return readableText(tryPipeTable(node, path))
return undefined
}
function readableText(text: string | undefined): Result<EmittedBlock> | undefined {
return text === undefined ? undefined : success(commonMarkText(text))
}
function commonMarkLine(text: Result<string>): Result<EmittedBlock> {
@@ -103,18 +113,17 @@ function commonMarkText(text: string): EmittedBlock {
return { fenceColons: 0, spelling: 'commonmark', text }
}
function commonMarkContainer(body: Result<EmittedBody>): Result<EmittedBlock> {
if (!body.ok) return body
return success({ ...body.value, spelling: 'commonmark' })
}
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path)
const content = node.content ?? []
if (directive.body === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path)
if (directive.body === 'code') return emitCodeDirective(node, directive, path)
const header = spellDirectiveHeader(node, directive)
if (header === undefined) return commonMarkLine(carriedBlock(node, path))
if (directive.body === 'none') return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` })
// spec/flavour.md, The CommonMark blocks: an empty paragraph is the leaf.
if (directive.body === 'none' || (node.type === 'paragraph' && 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)
if (!body.ok) return body
const fenceColons = Math.max(3, body.value.fenceColons + 1)
@@ -130,36 +139,36 @@ function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Re
return success({ fenceColons: 0, text: line.value })
}
function emitMediaSingle(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const image = tryImage(node, path)
if (image === undefined) return emitDirectiveBlock(node, directive, path, depth)
return success(commonMarkText(image))
}
function emitTable(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const pipe = tryPipeTable(node, path)
if (pipe === undefined) return emitDirectiveBlock(node, directive, path, depth)
return success(commonMarkText(pipe))
}
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
const validation = validateBlockNode(node, [], path)
if (!validation.ok) return validation
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
if (!commonMarkHead(node, [])) return undefined
const inner = emitBlocks(node.content ?? [], 'document', path, depth + 1)
if (!inner.ok) return inner
const text = inner.value.text
.split('\n')
.map((line) => (line === '' ? '>' : `> ${line}`))
.join('\n')
return success({ fenceColons: inner.value.fenceColons, text })
return success({ fenceColons: inner.value.fenceColons, spelling: 'commonmark', text })
}
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
if (node.attrs?.['language'] === carryName) return carriedBlock(node, path)
const validation = validateBlockNode(node, ['language'], path)
if (!validation.ok) return validation
const info = spellCodeFenceInfo(node.attrs?.['language'], path)
if (!info.ok) return info
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!commonMarkHead(node, ['language'])) return undefined
const info = fenceInfo(node.attrs?.['language'])
if (info === undefined) return undefined
const text = codeBlockText(node, path)
if (!text.ok) return text
return success(commonMarkText(fencedCodeBlock(info, text.value)))
}
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<EmittedBlock> {
const info = fenceInfo(node.attrs?.['language'])
const header = spellDirectiveHeader(node, directive, info === undefined ? [] : ['language'])
if (header === undefined) return commonMarkLine(carriedBlock(node, path))
const text = codeBlockText(node, path)
if (!text.ok) return text
return success({ fenceColons: 3, spelling: 'directive', text: `:::${header}\n${fencedCodeBlock(info ?? '', text.value)}\n:::` })
}
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
let text = ''
for (const [index, child] of (node.content ?? []).entries()) {
const childPath = [...path, 'content', index]
@@ -177,78 +186,57 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
text += child.text
}
return success(fencedCodeBlock(info.value, text))
return success(text)
}
function spellCodeFenceInfo(language: JsonValue | undefined, path: ConvertErrorPath): Result<string> {
if (language === undefined) return success('')
if (typeof language !== 'string') return failure('unsupported-node-shape', 'a codeBlock language is no string', path)
if (language === '') {
return failure('ambiguous-attribute-spelling', 'an empty codeBlock language and an absent one share one markdown spelling', path)
}
if (/[`\n\r]/.test(language) || language !== language.trim()) {
return failure('unspellable-code-block-language', 'a fence info string holds no backtick and no edge whitespace', path)
}
if (holdsEntityReference(language)) {
return failure('unspellable-code-block-language', 'a fence info string shaped like an entity reference decodes on the way back', path)
}
return success(language)
// spec/flavour.md, The CommonMark blocks: the languages an info string holds, the absent one as the empty string.
function fenceInfo(language: JsonValue | undefined): string | undefined {
if (language === undefined) return ''
if (typeof language !== 'string' || language === '' || language === carryName) return undefined
if (/[`\n\r]/.test(language) || language !== language.trim() || holdsEntityReference(language)) return undefined
return language
}
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<string> {
const validation = validateBlockNode(node, ['level'], path)
if (!validation.ok) return validation
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!commonMarkHead(node, ['level'])) return undefined
const level = node.attrs?.['level']
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) {
return failure('unsupported-heading-level', `no ATX heading spells level ${JSON.stringify(level ?? null)}`, path)
}
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
const hashes = '#'.repeat(level)
const content = node.content ?? []
if (content.length === 0) return success(hashes)
if (content.length === 0) return success(commonMarkText(hashes))
const line = emitInlineLine(content, 'heading', path)
if (!line.ok) return line
return success(`${hashes} ${line.value}`)
return success(commonMarkText(`${hashes} ${line.value}`))
}
function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
const ordered = node.type === 'orderedList'
const validation = validateBlockNode(node, ordered ? ['order'] : [], path)
if (!validation.ok) return validation
if (!commonMarkHead(node, ordered ? ['order'] : [])) return undefined
const items = node.content ?? []
const start = listStart(node, items.length, path)
if (!start.ok) return start
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)
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.value + offset}. ` : '- ', itemPath, depth)
const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ', itemPath, depth)
if (!emitted.ok) return emitted
fenceColons = Math.max(fenceColons, emitted.value.fenceColons)
lines.push(emitted.value.text)
}
return success({ fenceColons, text: lines.join('\n') })
return success({ fenceColons, spelling: 'commonmark', text: lines.join('\n') })
}
function listStart(node: AdfNode, items: number, path: ConvertErrorPath): Result<number> {
if (items === 0) return failure('unsupported-node-shape', `a ${node.type} holds at least one listItem`, path)
if (node.type !== 'orderedList') return success(0)
function listStart(node: AdfNode, items: number): number | undefined {
if (node.type !== 'orderedList') return 0
const start = node.attrs?.['order']
if (start === undefined || start === 1) {
return failure('ambiguous-attribute-spelling', 'an orderedList starting at 1 and one with no order share one markdown spelling', path)
}
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) {
return failure('unsupported-node-shape', `no list marker spells the order ${JSON.stringify(start)}`, path)
}
if (start + items - 1 > largestListMarker) {
return failure('unspellable-list-marker', `no list marker spells the ${items} items a list starting at ${start} needs`, path)
}
return success(start)
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined
return start + items - 1 > largestListMarker ? undefined : start
}
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
const validation = validateBlockNode(item, [], path)
if (!validation.ok) return validation
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() })
@@ -261,29 +249,20 @@ function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, dep
return success({ fenceColons: inner.value.fenceColons, text: lines.join('\n') })
}
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> {
const validation = validateBlockNode(node, [], path)
if (!validation.ok) return validation
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
const content = node.content ?? []
if (content.length === 0) return success({ fenceColons: 2, spelling: 'directive', text: '::paragraph' })
return commonMarkLine(emitInlineLine(content, 'paragraph', path))
if (content.length === 0 || !commonMarkHead(node, [])) return undefined
const line = emitInlineLine(content, 'paragraph', path)
if (!line.ok) return line
return success(commonMarkText(line.value))
}
function emitRule(node: AdfNode, path: ConvertErrorPath): Result<string> {
const validation = validateBlockNode(node, [], path)
if (!validation.ok) return validation
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a rule holds no content', path)
return success('---')
function emitRule(node: AdfNode): Result<EmittedBlock> | undefined {
if (!commonMarkHead(node, []) || (node.content ?? []).length > 0) return undefined
return success(commonMarkText('---'))
}
function validateBlockNode(node: AdfNode, spelled: readonly string[], path: ConvertErrorPath): Result<null> {
if ((node.marks ?? []).length > 0) {
return failure('unspelled-block-marks', `the canonical form has no place for the marks a ${node.type} carries`, path)
}
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path)
const unspelled = Object.keys(node.attrs ?? {}).find((key) => !spelled.includes(key))
if (unspelled !== undefined) {
return failure('unspelled-node-attribute', `the ${node.type} attribute ${unspelled} has no canonical markdown spelling`, path)
}
return success(null)
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))
}
+22 -11
View File
@@ -5,7 +5,7 @@ import { isBareToken, spellAttributes, spellJsonAttribute, vocabularyPairs } fro
export type BlockDirective = {
argument?: string
attributes: AttributeVocabulary
body: 'block' | 'inline' | 'none'
body: 'block' | 'code' | 'inline' | 'none'
}
const cellAttributes: AttributeVocabulary = {
@@ -28,7 +28,7 @@ const extensionAttributes: AttributeVocabulary = {
text: 'string',
}
const itemAttributes: AttributeVocabulary = { localId: 'string' }
const localIdAttributes: AttributeVocabulary = { localId: 'string' }
const mediaAttributes: AttributeVocabulary = {
alt: 'string',
@@ -45,44 +45,55 @@ const mediaAttributes: AttributeVocabulary = {
const syncBlockAttributes: AttributeVocabulary = { localId: 'string', resourceId: 'string' }
const blockDirectives: Readonly<Record<string, BlockDirective>> = {
blockTaskItem: { argument: 'state', attributes: itemAttributes, body: 'block' },
blockTaskItem: { argument: 'state', attributes: localIdAttributes, body: 'block' },
blockquote: { attributes: localIdAttributes, body: 'block' },
bodiedExtension: { attributes: extensionAttributes, body: 'block' },
bodiedSyncBlock: { attributes: syncBlockAttributes, body: 'block' },
caption: { attributes: itemAttributes, body: 'inline' },
bulletList: { attributes: localIdAttributes, body: 'block' },
caption: { attributes: localIdAttributes, body: 'inline' },
codeBlock: {
attributes: { hideLineNumbers: 'boolean', language: 'string', localId: 'string', uniqueId: 'string', wrap: 'boolean' },
body: 'code',
},
decisionItem: { attributes: { localId: 'string', state: 'string' }, body: 'inline' },
decisionList: { attributes: itemAttributes, body: 'block' },
decisionList: { attributes: localIdAttributes, body: 'block' },
expand: { attributes: expandAttributes, body: 'block' },
extension: { attributes: extensionAttributes, body: 'none' },
extensionFrame: { attributes: {}, body: 'block' },
heading: { attributes: { level: 'number', localId: 'string' }, body: 'inline' },
layoutColumn: { attributes: { localId: 'string', valign: 'string', width: 'number' }, body: 'block' },
layoutSection: { attributes: itemAttributes, body: 'block' },
layoutSection: { attributes: localIdAttributes, body: 'block' },
listItem: { attributes: localIdAttributes, 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' },
orderedList: { attributes: { localId: 'string', order: 'number' }, body: 'block' },
panel: {
argument: 'panelType',
attributes: { localId: 'string', panelColor: 'string', panelIcon: 'string', panelIconId: 'string', panelIconText: 'string' },
body: 'block',
},
paragraph: { attributes: localIdAttributes, body: 'inline' },
rule: { attributes: localIdAttributes, body: 'none' },
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' },
tableRow: { attributes: localIdAttributes, body: 'block' },
taskItem: { argument: 'state', attributes: localIdAttributes, body: 'inline' },
taskList: { attributes: localIdAttributes, body: 'block' },
}
export function blockDirective(type: string): BlockDirective | undefined {
return Object.hasOwn(blockDirectives, type) ? blockDirectives[type] : undefined
}
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective): string | 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)
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, [directive.argument, ...spelledByBody])
if (pairs === undefined) return undefined
const marks = node.marks ?? []
if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))])
+6 -2
View File
@@ -11,10 +11,14 @@ export function isBareToken(text: string): boolean {
return bareToken.test(text)
}
export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, slot: string | undefined): [string, string][] | undefined {
export function vocabularyPairs(
attrs: AdfAttributes,
vocabulary: AttributeVocabulary,
spelledElsewhere: readonly (string | undefined)[],
): [string, string][] | undefined {
const pairs: [string, string][] = []
for (const [key, value] of Object.entries(attrs)) {
if (key === slot) continue
if (spelledElsewhere.includes(key)) continue
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
if (kind === undefined) return undefined
const spelled = spellAttributeValue(value, kind)
+3 -2
View File
@@ -13,6 +13,7 @@ export type MarkSpelling =
const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
date: { attributes: { localId: 'string', timestamp: 'string' } },
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string' }, slot: 'text' },
hardBreak: { attributes: { localId: 'string', text: 'string' } },
inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' } },
mediaInline: {
attributes: {
@@ -52,11 +53,11 @@ 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])
return pairs === undefined ? undefined : spellAttributes(pairs)
}
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined {
const pairs = vocabularyPairs(mark.attrs ?? {}, vocabulary, undefined)
const pairs = vocabularyPairs(mark.attrs ?? {}, vocabulary, [])
return pairs === undefined ? undefined : spellAttributes(pairs)
}
+9 -9
View File
@@ -174,7 +174,7 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath {
function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean {
if (carried.has(index)) return true
return node.type !== 'hardBreak' && node.type !== 'text' && inlineDirective(node.type) === undefined
return node.type !== 'text' && inlineDirective(node.type) === undefined
}
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<Emission> {
@@ -187,18 +187,18 @@ 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 emitInlineDirective(node, directive, index, path)
if (node.type === 'hardBreak') return emitHardBreak(node, context, path)
return emitText(node, context, path)
if (directive === undefined) return emitText(node, context, path)
if (node.type === 'hardBreak') return emitHardBreak(node, directive, context, index, path)
return emitInlineDirective(node, directive, index, path)
}
function emitHardBreak(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result<Emission> {
const unspelled = Object.keys(node.attrs ?? {})[0]
if (unspelled !== undefined) return failure('unspelled-node-attribute', `the hardBreak attribute ${unspelled} has no canonical markdown spelling`, path)
function emitHardBreak(node: AdfNode, directive: InlineDirective, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
const empty = refuseContentAndText(node, path)
if (!empty.ok) return empty
if (context.spansLines && !context.atBlockEnd) return success({ segments: [syntax('\\\n')] })
return success({ segments: [syntax(spellLeafDirective('hardBreak', ''))] })
const attributes = spellInlineNodeAttributes(node, directive)
if (attributes === undefined) return success({ carry: { first: index, last: index } })
if (attributes === '' && context.spansLines && !context.atBlockEnd) return success({ segments: [syntax('\\\n')] })
return success({ segments: [syntax(spellLeafDirective('hardBreak', attributes))] })
}
function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: number, path: ConvertErrorPath): Result<Emission> {
-5
View File
@@ -1,19 +1,14 @@
export type ConvertErrorCode =
| 'ambiguous-attribute-spelling'
| 'not-an-adf-document'
| 'unspellable-adjacent-lists'
| 'unspellable-character'
| 'unspellable-code-block-language'
| 'unspellable-line-start'
| 'unspellable-link-destination'
| 'unspellable-link-title'
| 'unspellable-list-marker'
| 'unspellable-whitespace'
| 'unspelled-block-marks'
| 'unspelled-block-separation'
| 'unspelled-node-attribute'
| 'unsupported-document-version'
| 'unsupported-heading-level'
| 'unsupported-node-shape'
export type ConvertErrorPath = readonly (number | string)[]
+14
View File
@@ -134,6 +134,20 @@ detail is settled at its own milestone.
page of carried JSON. Fixtures and emitter together, and the three documents the answer
settles leave `corpus/unspellable/` as round-trip pairs: `block-local-id`,
`code-block-empty-language`, `ordered-list-start-one`.
**Settled** (the maintainer, 2026-08-27): the `codeBlock` directive's body is one fenced
code block, the language staying on the fence line so every renderer still highlights it;
a language no info string holds — empty, a backtick, edge whitespace, an entity reference
or the reserved `adf` — rides the `language` attribute with the fence bare, which retires
2d's carry for the reserved name along with the premise that left it no other spelling.
The plain spelling gives way wherever it cannot render what the node carries rather than
only where it has no place for it, so a heading level absent or outside 1-6 and an order
whose markers would run past 999999999 take the directive form too, and
`ambiguous-attribute-spelling`, `unspellable-code-block-language`,
`unspellable-list-marker`, `unspelled-block-marks` and `unsupported-heading-level` leave
`ConvertErrorCode`; content and placement refusals stay, which leaves the directive form
spelling an empty list or a non-`listItem` child that the plain form refuses. `order` is
the first marker, so `order: 1` keeps the plain `1.` — what a real payload carries — and a
list carrying no `order` has no number to take and takes the directive form.
- [ ] **3 — `markdownToAdf`.** The CommonMark parser is the largest single component; split it
into sub-items before starting (§15). Fixtures land with the code that reads them:
`corpus/normalization/` (setext, indented code, loose lists, `*`/`+` bullets, entity