Give the CommonMark blocks a directive form for what their spelling cannot hold
CI / gate (push) Successful in 5s

This commit is contained in:
2026-08-27 14:12:55 +02:00
parent 82cf28f176
commit 59b42ef0dc
30 changed files with 639 additions and 210 deletions
@@ -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" "node": ">=24"
}, },
"scripts": { "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" "typecheck": "tsc --noEmit"
}, },
"devDependencies": { "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 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 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. inline, so a `~~~` line opens a CommonMark tilde code fence.
- Bullet lists `- `; ordered lists incrementing `1.` `2.` `3.`, the first number taken from the - Bullet lists `- `; ordered lists incrementing `1.` `2.` `3.`, the first number the node's
node's `order` attribute. Continuation lines align with the first character after the marker `order` attribute. Continuation lines align with the first character after the marker
(two spaces for `- `, three for `1. `); blank lines inside an item are empty lines, none before (two spaces for `- `, three for `1. `); blank lines inside an item are empty lines, none before
a nested list. Blank lines between items normalize away; ADF does not record tightness. 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 `>`. - 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. inside it, directive syntax and `~~` included.
- Thematic break `---`. - Thematic break `---`.
- Hard break: backslash at end of line (survives editors that trim trailing spaces). Where - Hard break: backslash at end of line (survives editors that trim trailing spaces). Where
CommonMark admits no spelling — the end of a block, inside a heading — 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`. - An empty paragraph — real payloads carry them — is `::paragraph`.
- Links `[text](url)`; `<…>` around a destination containing spaces; title in double quotes. - Links `[text](url)`; `<…>` around a destination containing spaces; title in double quotes.
`<url>` autolink form only when the text equals the destination and the destination is a valid `<url>` autolink form only when the text equals the destination and the destination is a valid
@@ -125,8 +126,9 @@ positions canonicalize differently, each fitting where it sits:
- **Inline position**: `:adf{json="…"}` — compact serialization (keys sorted, no whitespace), - **Inline position**: `:adf{json="…"}` — compact serialization (keys sorted, no whitespace),
JSON-string-escaped into the attribute. JSON-string-escaped into the attribute.
The info string `adf` is reserved: a genuine `codeBlock` whose `language` is exactly `adf` is The info string `adf` is reserved: a genuine `codeBlock` whose `language` is exactly `adf` takes
itself emitted through the opaque carry, so the reservation stays absolute and stays lossless. 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 In block-directive positions (`::adf`, `:::adf`) the reserved name is a named error — the
carry's block form is the fence. 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 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. 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 ### Panel
Container; the arg is `panelType` (`custom` `error` `info` `note` `success` `tip` `warning`). 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 ## Inline nodes
Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of
the nodes below, those with a `text` attribute spell it in the content slot as plain text: `[]` is the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot
the empty string, absent content is the absent attribute, non-empty content parsing to anything as plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content parsing to anything
but one unmarked text node — adjacent identical-mark text nodes merged first — is a named 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 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 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). - `date` — `localId` (string), `timestamp` (string, epoch milliseconds).
- `emoji` — `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` (string). - `emoji` — `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` (string).
- `hardBreak` — `localId` (string), `text` (string).
- `inlineCard` — `data` (json), `localId` (string), `url` (string); real payloads carry one or - `inlineCard` — `data` (json), `localId` (string), `url` (string); real payloads carry one or
the other. the other.
- `mediaInline` — `alt` (string), `collection` (string), `data` (json), `height` (number), `id` - `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', () => { 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' } 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' }), list))), ['content', 1, 'content', 1, 'content', 0])
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { type: 'text' })))), ['content', 0, 'content', 1]) 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') assert.equal(code(adfToMarkdown({ type: 'doc', version: 2 })), 'unsupported-document-version')
}) })
test('refuses an attribute the canonical form does not spell', () => { test('refuses a text node 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(paragraph({ attrs: { localId: 'a' }, text: 'x', type: 'text' })))), '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 marks on a block node', () => { test('spells a CommonMark block as a directive where its own spelling holds neither attribute nor mark', () => {
assert.equal(code(adfToMarkdown(document({ marks: [{ type: 'border' }], type: 'blockquote' }))), 'unspelled-block-marks') 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' }] 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(markdown(adfToMarkdown(document({ content: items, type: 'orderedList' }))), '::::orderedList\n:::listItem\nx\n:::\n::::\n')
assert.equal(code(adfToMarkdown(document({ attrs: { order: 1 }, content: items, type: 'orderedList' }))), 'ambiguous-attribute-spelling') 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') 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', () => { test('spells a code block language no info string holds as an attribute', () => {
assert.equal(code(adfToMarkdown(document({ attrs: { language: '' }, type: 'codeBlock' }))), 'ambiguous-attribute-spelling') const language = (value: string): string => markdown(adfToMarkdown(document({ attrs: { language: value }, type: 'codeBlock' })))
assert.equal(code(adfToMarkdown(document({ attrs: { language: 'a`b' }, type: 'codeBlock' }))), 'unspellable-code-block-language') assert.equal(language(''), ':::codeBlock {language=""}\n```\n```\n:::\n')
assert.equal(code(adfToMarkdown(document({ attrs: { language: ' sql' }, type: 'codeBlock' }))), 'unspellable-code-block-language') assert.equal(language('a`b'), ':::codeBlock {language="a\\u0060b"}\n```\n```\n:::\n')
assert.equal(code(adfToMarkdown(document({ attrs: { language: '&#97;df' }, type: 'codeBlock' }))), 'unspellable-code-block-language') 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', () => { 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({ 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(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({ 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') 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', () => { test('spells the code block whose language is the reserved info string', () => {
assert.equal( assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), ':::codeBlock {language=adf}\n```\n```\n:::\n')
markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))),
'```adf\n{\n "attrs": {\n "language": "adf"\n },\n "type": "codeBlock"\n}\n```\n',
)
}) })
test('breaks a mark run at the node it carries', () => { 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') 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 item: AdfNode = { content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }
const list = (order: number, items: number): AdfDocument => const list = (order: number, items: number): AdfDocument =>
document({ attrs: { order }, content: Array.from({ length: items }, () => item), type: 'orderedList' }) 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(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', () => { 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', () => { test('spells a heading level no ATX heading fits as a directive', () => {
assert.equal(code(adfToMarkdown(document({ attrs: { level: 7 }, content: [{ text: 'x', type: 'text' }], type: 'heading' }))), 'unsupported-heading-level') assert.equal(markdown(adfToMarkdown(document({ attrs: { level: 7 }, content: [{ text: 'x', type: 'text' }], type: 'heading' }))), ':::heading {level=7}\nx\n:::\n')
assert.equal(code(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'heading' }))), 'unsupported-heading-level') 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', () => { 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(cell('tableHeader', text('Part'))))))
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader')))))) assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader'))))))
assert.ok(directive(adfToMarkdown(table(row(cell('tableHeader', text('a'), text('b'))))))) 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( assert.equal(
markdown(adfToMarkdown(table(row(cell('tableHeader', { content: [{ attrs: { url: 'a|b' }, type: 'blockCard' }], type: 'paragraph' }))))), 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', '| :adf{json="{\\"attrs\\":{\\"url\\":\\"a\\u007cb\\"},\\"type\\":\\"blockCard\\"}"} |\n| --- |\n',
+86 -107
View File
@@ -54,17 +54,19 @@ function emitBlocks(nodes: readonly AdfNode[], container: BlockContainer, path:
} }
function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): Result<string> { function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): Result<string> {
if (listTypes.includes(next.node.type) && previous.node.type === next.node.type) { 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) 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 (container === 'list-item') {
if (!interruptsParagraph(next.node)) { 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 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') if (previous.spelling === 'directive' && next.spelling === 'directive') return success('\n')
return failure( return failure(
'unspelled-block-separation', 'unspelled-block-separation',
@@ -79,19 +81,27 @@ function interruptsParagraph(node: AdfNode): boolean {
} }
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> { 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) const directive = blockDirective(node.type)
if (directive !== undefined) { if (directive === undefined) return commonMarkLine(carriedBlock(node, path))
if (node.type === 'mediaSingle') return emitMediaSingle(node, directive, path, depth) const readable = readableBlock(node, path, depth)
if (node.type === 'table') return emitTable(node, directive, path, depth) if (readable !== undefined) return readable
return emitDirectiveBlock(node, directive, path, depth) return emitDirectiveBlock(node, directive, path, depth)
} }
return commonMarkLine(carriedBlock(node, path))
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> { function commonMarkLine(text: Result<string>): Result<EmittedBlock> {
@@ -103,18 +113,17 @@ function commonMarkText(text: string): EmittedBlock {
return { fenceColons: 0, spelling: 'commonmark', text } 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> { 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) if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path)
const content = node.content ?? [] 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 === '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) const header = spellDirectiveHeader(node, directive)
if (header === undefined) return commonMarkLine(carriedBlock(node, path)) 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) const body = directive.body === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1)
if (!body.ok) return body if (!body.ok) return body
const fenceColons = Math.max(3, body.value.fenceColons + 1) 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 }) return success({ fenceColons: 0, text: line.value })
} }
function emitMediaSingle(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> { function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined {
const image = tryImage(node, path) if (!commonMarkHead(node, [])) return undefined
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
const inner = emitBlocks(node.content ?? [], 'document', path, depth + 1) const inner = emitBlocks(node.content ?? [], 'document', path, depth + 1)
if (!inner.ok) return inner if (!inner.ok) return inner
const text = inner.value.text const text = inner.value.text
.split('\n') .split('\n')
.map((line) => (line === '' ? '>' : `> ${line}`)) .map((line) => (line === '' ? '>' : `> ${line}`))
.join('\n') .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> { function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (node.attrs?.['language'] === carryName) return carriedBlock(node, path) if (!commonMarkHead(node, ['language'])) return undefined
const validation = validateBlockNode(node, ['language'], path) const info = fenceInfo(node.attrs?.['language'])
if (!validation.ok) return validation if (info === undefined) return undefined
const info = spellCodeFenceInfo(node.attrs?.['language'], path) const text = codeBlockText(node, path)
if (!info.ok) return info 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 = '' let text = ''
for (const [index, child] of (node.content ?? []).entries()) { for (const [index, child] of (node.content ?? []).entries()) {
const childPath = [...path, 'content', index] 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) if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
text += child.text text += child.text
} }
return success(fencedCodeBlock(info.value, text)) return success(text)
} }
function spellCodeFenceInfo(language: JsonValue | undefined, path: ConvertErrorPath): Result<string> { // spec/flavour.md, The CommonMark blocks: the languages an info string holds, the absent one as the empty string.
if (language === undefined) return success('') function fenceInfo(language: JsonValue | undefined): string | undefined {
if (typeof language !== 'string') return failure('unsupported-node-shape', 'a codeBlock language is no string', path) if (language === undefined) return ''
if (language === '') { if (typeof language !== 'string' || language === '' || language === carryName) return undefined
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() || holdsEntityReference(language)) return undefined
} return language
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)
} }
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<string> { function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
const validation = validateBlockNode(node, ['level'], path) if (!commonMarkHead(node, ['level'])) return undefined
if (!validation.ok) return validation
const level = node.attrs?.['level'] const level = node.attrs?.['level']
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) { if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
return failure('unsupported-heading-level', `no ATX heading spells level ${JSON.stringify(level ?? null)}`, path)
}
const hashes = '#'.repeat(level) const hashes = '#'.repeat(level)
const content = node.content ?? [] 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) const line = emitInlineLine(content, 'heading', path)
if (!line.ok) return line 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 ordered = node.type === 'orderedList'
const validation = validateBlockNode(node, ordered ? ['order'] : [], path) if (!commonMarkHead(node, ordered ? ['order'] : [])) return undefined
if (!validation.ok) return validation
const items = node.content ?? [] const items = node.content ?? []
const start = listStart(node, items.length, path) const start = listStart(node, items.length)
if (!start.ok) return start 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[] = [] const lines: string[] = []
let fenceColons = 0 let fenceColons = 0
for (const [offset, item] of items.entries()) { for (const [offset, item] of items.entries()) {
const itemPath = [...path, 'content', offset] const itemPath = [...path, 'content', offset]
if (item.type !== 'listItem') return failure('unsupported-node-shape', `a ${node.type} holds listItem nodes only`, itemPath) 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 if (!emitted.ok) return emitted
fenceColons = Math.max(fenceColons, emitted.value.fenceColons) fenceColons = Math.max(fenceColons, emitted.value.fenceColons)
lines.push(emitted.value.text) lines.push(emitted.value.text)
} }
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> { function listStart(node: AdfNode, items: number): number | undefined {
if (items === 0) return failure('unsupported-node-shape', `a ${node.type} holds at least one listItem`, path) if (node.type !== 'orderedList') return 0
if (node.type !== 'orderedList') return success(0)
const start = node.attrs?.['order'] const start = node.attrs?.['order']
if (start === undefined || start === 1) { if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined
return failure('ambiguous-attribute-spelling', 'an orderedList starting at 1 and one with no order share one markdown spelling', path) return start + items - 1 > largestListMarker ? undefined : start
}
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)
} }
function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, depth: number): Result<EmittedBody> { 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) const inner = emitBlocks(item.content ?? [], 'list-item', path, depth + 1)
if (!inner.ok) return inner if (!inner.ok) return inner
if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() }) if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() })
@@ -261,29 +249,20 @@ function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, dep
return success({ fenceColons: inner.value.fenceColons, text: lines.join('\n') }) return success({ fenceColons: inner.value.fenceColons, text: lines.join('\n') })
} }
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> { function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
const validation = validateBlockNode(node, [], path)
if (!validation.ok) return validation
const content = node.content ?? [] const content = node.content ?? []
if (content.length === 0) return success({ fenceColons: 2, spelling: 'directive', text: '::paragraph' }) if (content.length === 0 || !commonMarkHead(node, [])) return undefined
return commonMarkLine(emitInlineLine(content, 'paragraph', path)) const line = emitInlineLine(content, 'paragraph', path)
if (!line.ok) return line
return success(commonMarkText(line.value))
} }
function emitRule(node: AdfNode, path: ConvertErrorPath): Result<string> { function emitRule(node: AdfNode): Result<EmittedBlock> | undefined {
const validation = validateBlockNode(node, [], path) if (!commonMarkHead(node, []) || (node.content ?? []).length > 0) return undefined
if (!validation.ok) return validation return success(commonMarkText('---'))
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a rule holds no content', path)
return success('---')
} }
function validateBlockNode(node: AdfNode, spelled: readonly string[], path: ConvertErrorPath): Result<null> { function commonMarkHead(node: AdfNode, spelled: readonly string[]): boolean {
if ((node.marks ?? []).length > 0) { if ((node.marks ?? []).length > 0 || node.text !== undefined) return false
return failure('unspelled-block-marks', `the canonical form has no place for the marks a ${node.type} carries`, path) return Object.keys(node.attrs ?? {}).every((key) => spelled.includes(key))
}
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)
} }
+22 -11
View File
@@ -5,7 +5,7 @@ import { isBareToken, spellAttributes, spellJsonAttribute, vocabularyPairs } fro
export type BlockDirective = { export type BlockDirective = {
argument?: string argument?: string
attributes: AttributeVocabulary attributes: AttributeVocabulary
body: 'block' | 'inline' | 'none' body: 'block' | 'code' | 'inline' | 'none'
} }
const cellAttributes: AttributeVocabulary = { const cellAttributes: AttributeVocabulary = {
@@ -28,7 +28,7 @@ const extensionAttributes: AttributeVocabulary = {
text: 'string', text: 'string',
} }
const itemAttributes: AttributeVocabulary = { localId: 'string' } const localIdAttributes: AttributeVocabulary = { localId: 'string' }
const mediaAttributes: AttributeVocabulary = { const mediaAttributes: AttributeVocabulary = {
alt: 'string', alt: 'string',
@@ -45,44 +45,55 @@ const mediaAttributes: AttributeVocabulary = {
const syncBlockAttributes: AttributeVocabulary = { localId: 'string', resourceId: 'string' } const syncBlockAttributes: AttributeVocabulary = { localId: 'string', resourceId: 'string' }
const blockDirectives: Readonly<Record<string, BlockDirective>> = { 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' }, bodiedExtension: { attributes: extensionAttributes, body: 'block' },
bodiedSyncBlock: { attributes: syncBlockAttributes, 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' }, decisionItem: { attributes: { localId: 'string', state: 'string' }, body: 'inline' },
decisionList: { attributes: itemAttributes, body: 'block' }, decisionList: { attributes: localIdAttributes, body: 'block' },
expand: { attributes: expandAttributes, body: 'block' }, expand: { attributes: expandAttributes, body: 'block' },
extension: { attributes: extensionAttributes, body: 'none' }, extension: { attributes: extensionAttributes, body: 'none' },
extensionFrame: { attributes: {}, body: 'block' }, extensionFrame: { attributes: {}, body: 'block' },
heading: { attributes: { level: 'number', localId: 'string' }, body: 'inline' },
layoutColumn: { attributes: { localId: 'string', valign: 'string', width: 'number' }, body: 'block' }, 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' }, media: { attributes: mediaAttributes, body: 'none' },
mediaGroup: { attributes: {}, body: 'block' }, mediaGroup: { attributes: {}, body: 'block' },
mediaSingle: { attributes: { layout: 'string', localId: 'string', width: 'number', widthType: 'string' }, body: 'block' }, mediaSingle: { attributes: { layout: 'string', localId: 'string', width: 'number', widthType: 'string' }, body: 'block' },
multiBodiedExtension: { attributes: extensionAttributes, body: 'block' }, multiBodiedExtension: { attributes: extensionAttributes, body: 'block' },
nestedExpand: { attributes: expandAttributes, body: 'block' }, nestedExpand: { attributes: expandAttributes, body: 'block' },
orderedList: { attributes: { localId: 'string', order: 'number' }, body: 'block' },
panel: { panel: {
argument: 'panelType', argument: 'panelType',
attributes: { localId: 'string', panelColor: 'string', panelIcon: 'string', panelIconId: 'string', panelIconText: 'string' }, attributes: { localId: 'string', panelColor: 'string', panelIcon: 'string', panelIconId: 'string', panelIconText: 'string' },
body: 'block', body: 'block',
}, },
paragraph: { attributes: localIdAttributes, body: 'inline' },
rule: { attributes: localIdAttributes, body: 'none' },
syncBlock: { attributes: syncBlockAttributes, body: 'none' }, syncBlock: { attributes: syncBlockAttributes, body: 'none' },
table: { attributes: { displayMode: 'string', isNumberColumnEnabled: 'boolean', layout: 'string', localId: 'string', width: 'number' }, body: 'block' }, table: { attributes: { displayMode: 'string', isNumberColumnEnabled: 'boolean', layout: 'string', localId: 'string', width: 'number' }, body: 'block' },
tableCell: { attributes: cellAttributes, body: 'block' }, tableCell: { attributes: cellAttributes, body: 'block' },
tableHeader: { attributes: cellAttributes, body: 'block' }, tableHeader: { attributes: cellAttributes, body: 'block' },
tableRow: { attributes: itemAttributes, body: 'block' }, tableRow: { attributes: localIdAttributes, body: 'block' },
taskItem: { argument: 'state', attributes: itemAttributes, body: 'inline' }, taskItem: { argument: 'state', attributes: localIdAttributes, body: 'inline' },
taskList: { attributes: itemAttributes, body: 'block' }, taskList: { attributes: localIdAttributes, body: 'block' },
} }
export function blockDirective(type: string): BlockDirective | undefined { export function blockDirective(type: string): BlockDirective | undefined {
return Object.hasOwn(blockDirectives, type) ? blockDirectives[type] : 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) const argument = spellArgument(node, directive)
if (argument === undefined) return undefined 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 if (pairs === undefined) return undefined
const marks = node.marks ?? [] const marks = node.marks ?? []
if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))]) if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))])
+6 -2
View File
@@ -11,10 +11,14 @@ export function isBareToken(text: string): boolean {
return bareToken.test(text) 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][] = [] const pairs: [string, string][] = []
for (const [key, value] of Object.entries(attrs)) { 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 const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
if (kind === undefined) return undefined if (kind === undefined) return undefined
const spelled = spellAttributeValue(value, kind) const spelled = spellAttributeValue(value, kind)
+3 -2
View File
@@ -13,6 +13,7 @@ export type MarkSpelling =
const inlineDirectives: Readonly<Record<string, InlineDirective>> = { const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
date: { attributes: { localId: 'string', timestamp: 'string' } }, date: { attributes: { localId: 'string', timestamp: 'string' } },
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string' }, slot: 'text' }, emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string' }, slot: 'text' },
hardBreak: { attributes: { localId: 'string', text: 'string' } },
inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' } }, inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' } },
mediaInline: { mediaInline: {
attributes: { attributes: {
@@ -52,11 +53,11 @@ export function markSpelling(type: string): MarkSpelling | undefined {
} }
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined { export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined {
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot) const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, [directive.slot])
return pairs === undefined ? undefined : spellAttributes(pairs) return pairs === undefined ? undefined : spellAttributes(pairs)
} }
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined { 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) 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 { function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean {
if (carried.has(index)) return true 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> { 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) const types = (node.marks ?? []).map((mark) => mark.type)
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path) if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
const directive = inlineDirective(node.type) const directive = inlineDirective(node.type)
if (directive !== undefined) return emitInlineDirective(node, directive, index, path) if (directive === undefined) return emitText(node, context, path)
if (node.type === 'hardBreak') return emitHardBreak(node, context, path) if (node.type === 'hardBreak') return emitHardBreak(node, directive, context, index, path)
return emitText(node, context, path) return emitInlineDirective(node, directive, index, path)
} }
function emitHardBreak(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result<Emission> { function emitHardBreak(node: AdfNode, directive: InlineDirective, context: InlineContext, index: number, 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)
const empty = refuseContentAndText(node, path) const empty = refuseContentAndText(node, path)
if (!empty.ok) return empty if (!empty.ok) return empty
if (context.spansLines && !context.atBlockEnd) return success({ segments: [syntax('\\\n')] }) const attributes = spellInlineNodeAttributes(node, directive)
return success({ segments: [syntax(spellLeafDirective('hardBreak', ''))] }) 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> { function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: number, path: ConvertErrorPath): Result<Emission> {
-5
View File
@@ -1,19 +1,14 @@
export type ConvertErrorCode = export type ConvertErrorCode =
| 'ambiguous-attribute-spelling'
| 'not-an-adf-document' | 'not-an-adf-document'
| 'unspellable-adjacent-lists' | 'unspellable-adjacent-lists'
| 'unspellable-character' | 'unspellable-character'
| 'unspellable-code-block-language'
| 'unspellable-line-start' | 'unspellable-line-start'
| 'unspellable-link-destination' | 'unspellable-link-destination'
| 'unspellable-link-title' | 'unspellable-link-title'
| 'unspellable-list-marker'
| 'unspellable-whitespace' | 'unspellable-whitespace'
| 'unspelled-block-marks'
| 'unspelled-block-separation' | 'unspelled-block-separation'
| 'unspelled-node-attribute' | 'unspelled-node-attribute'
| 'unsupported-document-version' | 'unsupported-document-version'
| 'unsupported-heading-level'
| 'unsupported-node-shape' | 'unsupported-node-shape'
export type ConvertErrorPath = readonly (number | string)[] 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 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`, settles leave `corpus/unspellable/` as round-trip pairs: `block-local-id`,
`code-block-empty-language`, `ordered-list-start-one`. `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 - [ ] **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: into sub-items before starting (§15). Fixtures land with the code that reads them:
`corpus/normalization/` (setext, indented code, loose lists, `*`/`+` bullets, entity `corpus/normalization/` (setext, indented code, loose lists, `*`/`+` bullets, entity