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
36 changed files with 741 additions and 264 deletions
+6 -3
View File
@@ -125,9 +125,12 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
`matchEmphasis` transcribes the reference `process_emphasis` line for line, and its closer walk and `matchEmphasis` transcribes the reference `process_emphasis` line for line, and its closer walk and
opener search stay whole: broken into named steps they drift from the algorithm being faithful is opener search stay whole: broken into named steps they drift from the algorithm being faithful is
the whole point of. the whole point of.
- A readable spelling tried ahead of a general one — the image, the pipe table, a pipe cell — - A readable spelling tried ahead of a general one — a CommonMark block, the image, the pipe
returns `string | undefined`, never a `Result`: any failure is the fallback signal, and the table, a pipe cell — gives way with `undefined` for every shape it cannot spell, and fails only
general form owns the refusal. Refusing there refuses a document the general form spells. where the general form fails on the same node. Refusing there refuses a document the general
form spells, so a refusal the general form does not share belongs in the general form or
nowhere — save the nested list a tight spelling would swallow, whose refusal the
tight-versus-blank answer owns (`todo.md` 2b).
- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an - Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an
attribute's and a carried node's alike — are all held to 500 levels, so a deep document is a attribute's and a carried node's alike — are all held to 500 levels, so a deep document is a
`Result` rather than the stack overflow that waits near 2000. `Result` rather than the stack overflow that waits near 2000.
@@ -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,79 @@
{
"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"
},
{
"attrs": {
"panelType": "info"
},
"content": [
{
"attrs": {
"language": "text",
"wrap": true
},
"content": [
{
"text": ":::",
"type": "text"
}
],
"type": "codeBlock"
}
],
"type": "panel"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,31 @@
:::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
```
:::
::::panel info
:::codeBlock {wrap=true}
```text
:::
```
:::
::::
@@ -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
:::
::::
@@ -75,6 +75,12 @@
} }
], ],
"type": "taskItem" "type": "taskItem"
},
{
"attrs": {
"state": "TODO"
},
"type": "taskItem"
} }
], ],
"type": "taskList" "type": "taskList"
@@ -14,5 +14,6 @@ Then sign off.
:::taskItem TODO {localId=01a033bd-a22d-7fb5-be9d-89d1aa261112} :::taskItem TODO {localId=01a033bd-a22d-7fb5-be9d-89d1aa261112}
Nested step Nested step
::: :::
::taskItem TODO
:::: ::::
::::: :::::
@@ -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": {
+52 -17
View File
@@ -16,10 +16,11 @@ 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
a nested list. Blank lines between items normalize away; ADF does not record tightness. between a nested list and a CommonMark block above it. Blank lines between items normalize
away; ADF does not record tightness.
- Blockquotes prefix lines with `> `; a blank line inside a blockquote is a bare `>`. - Blockquotes prefix lines with `> `; a blank line inside a blockquote is a bare `>`.
- ATX headings (`#``######`); setext input normalizes to ATX. - ATX headings (`#``######`); setext input normalizes to ATX.
- Code fences ``` with the node's language as info string, the fence lengthened past any backtick - Code fences ``` with the node's language as info string, the fence lengthened past any backtick
@@ -31,7 +32,8 @@ normalizes to it through the round-trip.
inside it, directive syntax and `~~` included. inside it, directive syntax and `~~` included.
- Thematic break `---`. - Thematic break `---`.
- Hard break: backslash at end of line (survives editors that trim trailing spaces). Where - Hard break: backslash at end of line (survives editors that trim trailing spaces). Where
CommonMark admits no spelling — the end of a block, inside a heading — it is `:hardBreak{}`. CommonMark admits no spelling — the end of a block, inside an ATX 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
@@ -84,7 +86,8 @@ fence or opaque carry is content. Canonical form uses minimal lengths.
Directive fence lines follow code-fence indentation (up to three spaces relative to their Directive fence lines follow code-fence indentation (up to three spaces relative to their
container); trailing whitespace on a fence line is tolerated in input, never emitted. container); trailing whitespace on a fence line is tolerated in input, never emitted.
**Leaf block**: `::name {attrs}` — a block-position node with no body. **Leaf block**: `::name arg {attrs}` — a block-position node with no body, `arg` reading as
above.
**Claiming at block level**, symmetric with inline: a line whose leading run of two or more **Claiming at block level**, symmetric with inline: a line whose leading run of two or more
colons is followed immediately by a name character is claimed and must parse fully as a container colons is followed immediately by a name character is claimed and must parse fully as a container
@@ -125,8 +128,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.
@@ -153,14 +157,44 @@ editor-normal ADF reads an empty attrs object, marks array or content array as t
Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a
`json` value: `::::layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`. `json` value: `::::layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`.
Three child nodes hold inline content rather than blocks (`caption`, `decisionItem`, A section saying its body is inline takes at most one paragraph, whose inline content becomes
`taskItem`): their body is at most one paragraph, whose inline content becomes the node's the node's `content`; any other body is a named error, and a node holding no content is the leaf.
`content`; any other body is a named error.
A node the sections cannot spell rides the opaque carry: an attrs key its section does not A node the sections cannot spell rides the opaque carry: an attrs key its section does not
list, a value that is not the section's type, or an arg-slot value that is no bare token. In list, a value that is not the section's type, or an arg-slot value that is no bare token. In
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 carries back — empty, the
reserved `adf`, or holding a backtick, a backslash, a control character, edge whitespace or an
entity reference — rides the `language` attribute instead and the fence carries no info string;
writing both is a named error.
- `heading` — container, inline body. Attributes: `level` (number), `localId`. `level` is the `#`
count, so a heading carrying none, or one that is no whole number from 1 to 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, one that is no whole number from 0, or one
whose markers would run past 999999999, has no CommonMark spelling.
- `paragraph` — container, inline body; 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,15 +365,16 @@ 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
but one unmarked text node — adjacent identical-mark text nodes merged first — is a named parsing to anything but one unmarked text node — adjacent identical-mark text nodes merged first —
error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the is a named error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach
slot. The rest take no content, `:text` included; content on a node that takes none is a named into the slot. The rest take no content, `:text` included; content on a node that takes none is a
error. named error.
- `date` — `localId` (string), `timestamp` (string, epoch milliseconds). - `date` — `localId` (string), `timestamp` (string, epoch milliseconds).
- `emoji` — `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` (string). - `emoji` — `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` (string).
- `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`
+5
View File
@@ -29,6 +29,11 @@ const documentKeys = ['content', 'type', 'version']
const markKeys = ['attrs', 'type'] const markKeys = ['attrs', 'type']
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type'] const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
if ((node.marks ?? []).length > 0 || node.text !== undefined) return false
return holdsOnly(node.attrs ?? {}, attributes)
}
export function isAdfDocument(value: unknown): value is AdfDocument { export function isAdfDocument(value: unknown): value is AdfDocument {
if (!isRecord(value) || !holdsOnly(value, documentKeys)) return false if (!isRecord(value) || !holdsOnly(value, documentKeys)) return false
if (value['type'] !== 'doc') return false if (value['type'] !== 'doc') return false
+51 -38
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,37 @@ 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('carries a text node attribute no spelling holds', () => {
assert.equal(code(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'paragraph' }))), 'unspelled-node-attribute') assert.equal(
assert.equal(code(adfToMarkdown(document({ attrs: { wrap: true }, type: 'codeBlock' }))), 'unspelled-node-attribute') markdown(adfToMarkdown(document(paragraph({ attrs: { localId: 'a' }, text: 'x', type: 'text' })))),
assert.equal(code(adfToMarkdown(document(paragraph({ attrs: { localId: 'a' }, type: 'hardBreak' }, { text: 'x', type: 'text' })))), 'unspelled-node-attribute') ':adf{json="{\\"attrs\\":{\\"localId\\":\\"a\\"},\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n',
)
}) })
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')
assert.equal(language('foo\\+bar'), ':::codeBlock {language="foo\\\\+bar"}\n```\n```\n:::\n')
assert.equal(language('a\u0000b'), ':::codeBlock {language="a\\u0000b"}\n```\n```\n:::\n')
assert.equal(language('a\tb'), ':::codeBlock {language="a\\tb"}\n```\n```\n:::\n')
}) })
test('refuses a link destination CommonMark cannot spell', () => { test('refuses a link destination CommonMark cannot spell', () => {
@@ -131,15 +140,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', () => {
@@ -161,18 +166,21 @@ test('refuses a carried node nested deeper than the emitter carries', () => {
test('refuses a node whose content model the canonical form cannot emit', () => { test('refuses a node whose content model the canonical form cannot emit', () => {
assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), 'unsupported-node-shape')
assert.equal(code(adfToMarkdown(document({ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(document({ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape')
assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), 'unsupported-node-shape')
assert.equal(code(adfToMarkdown(document({ type: 'bulletList' }))), 'unsupported-node-shape')
assert.equal(code(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), 'unsupported-node-shape')
}) })
test('refuses an ordered list no marker spells', () => { test('spells a list its own content shape cannot hold as a directive', () => {
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), ':::bulletList\n::paragraph\n:::\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), ':::bulletList\n:::\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), ':::orderedList {order=2}\n:::\n')
})
test('spells an ordered list no marker fits as a directive', () => {
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 +190,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', () => {
@@ -322,12 +330,12 @@ test('escapes a hyphen underline a hard break would expose', () => {
assert.equal(line('=='), 'foo\\\n\\==\n') assert.equal(line('=='), 'foo\\\n\\==\n')
}) })
test('refuses a list item whose marker completes a thematic break', () => { test('spells a list item whose marker completes a thematic break as a directive', () => {
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' }) const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' })
assert.equal(code(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), 'unspellable-line-start') assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '::::bulletList\n:::listItem\n---\n:::\n::::\n')
const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' } const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' }
assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n') assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n')
assert.equal(code(adfToMarkdown(document({ content: [item(nested)], type: 'bulletList' }))), 'unspellable-line-start') assert.equal(markdown(adfToMarkdown(document({ content: [item(nested)], type: 'bulletList' }))), '::::bulletList\n:::listItem\n- -\n:::\n::::\n')
}) })
test('refuses the characters CommonMark rewrites', () => { test('refuses the characters CommonMark rewrites', () => {
@@ -383,7 +391,8 @@ test('spells a block directive as its node type, arg and attributes', () => {
const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' }) const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' })
assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), ':::panel warning\nx\n:::\n') assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), ':::panel warning\nx\n:::\n')
assert.equal(markdown(adfToMarkdown(panel({}))), ':::panel\nx\n:::\n') assert.equal(markdown(adfToMarkdown(panel({}))), ':::panel\nx\n:::\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), ':::caption\n:::\n') assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), ':::caption\nx\n:::\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '::caption\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '::syncBlock {localId=a}\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '::syncBlock {localId=a}\n')
}) })
@@ -430,9 +439,10 @@ test('separates two directive blocks in a container body by one line, two Common
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' }) const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
const panel = (...content: AdfNode[]): AdfDocument => document({ attrs: { panelType: 'info' }, content, type: 'panel' }) const panel = (...content: AdfNode[]): AdfDocument => document({ attrs: { panelType: 'info' }, content, type: 'panel' })
assert.equal(markdown(adfToMarkdown(panel(text('a'), text('b')))), ':::panel info\na\n\nb\n:::\n') assert.equal(markdown(adfToMarkdown(panel(text('a'), text('b')))), ':::panel info\na\n\nb\n:::\n')
assert.equal(markdown(adfToMarkdown(panel({ type: 'caption' }, { type: 'caption' }))), '::::panel info\n:::caption\n:::\n:::caption\n:::\n::::\n') const caption: AdfNode = { content: [{ text: 'c', type: 'text' }], type: 'caption' }
assert.equal(code(adfToMarkdown(panel(text('a'), { type: 'caption' }))), 'unspelled-block-separation') assert.equal(markdown(adfToMarkdown(panel(caption, caption))), '::::panel info\n:::caption\nc\n:::\n:::caption\nc\n:::\n::::\n')
assert.equal(code(adfToMarkdown(panel({ type: 'caption' }, text('a')))), 'unspelled-block-separation') assert.equal(code(adfToMarkdown(panel(text('a'), caption))), 'unspelled-block-separation')
assert.equal(code(adfToMarkdown(panel(caption, text('a')))), 'unspelled-block-separation')
assert.equal(code(adfToMarkdown(panel(paragraph(), text('a')))), 'unspelled-block-separation') assert.equal(code(adfToMarkdown(panel(paragraph(), text('a')))), 'unspelled-block-separation')
}) })
@@ -452,6 +462,9 @@ test('spells the image form for exactly the centered external media shape', () =
assert.ok(fallback({ alt: 4, type: 'external', url })) assert.ok(fallback({ alt: 4, type: 'external', url }))
assert.ok(fallback({ type: 'external', url: 4 })) assert.ok(fallback({ type: 'external', url: 4 }))
assert.equal(code(adfToMarkdown(single({ type: 'external', url }, paragraph()))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(single({ type: 'external', url }, paragraph()))), 'unsupported-node-shape')
const media: AdfNode = { attrs: { type: 'external', url }, type: 'media' }
assert.equal(code(adfToMarkdown(document({ attrs: { layout: 'center' }, content: [media], text: 'x', type: 'mediaSingle' }))), 'unsupported-node-shape')
assert.equal(code(adfToMarkdown(document({ attrs: { layout: 'center' }, content: [{ ...media, text: 'x' }], type: 'mediaSingle' }))), 'unsupported-node-shape')
}) })
test('spells a mediaSingle the image form does not fit as a directive', () => { test('spells a mediaSingle the image form does not fit as a directive', () => {
@@ -480,7 +493,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',
+93 -125
View File
@@ -7,8 +7,8 @@ import { emitInlineLine } from './markdown-inline.ts'
import { tryPipeTable } from './markdown-pipe-table.ts' import { tryPipeTable } from './markdown-pipe-table.ts'
import { carriedBlock, carryName } from './opaque-carry.ts' import { carriedBlock, carryName } from './opaque-carry.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { holdsEntityReference, holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts' import { holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts'
import { isAdfDocument } from './adf-document.ts' import { carriesOnly, isAdfDocument } from './adf-document.ts'
import { largestNesting } from './nesting.ts' import { largestNesting } from './nesting.ts'
import { fencedCodeBlock } from './backtick-runs.ts' import { fencedCodeBlock } from './backtick-runs.ts'
@@ -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'
return failure('unspellable-adjacent-lists', `two adjacent ${next.node.type} nodes read back as one list`, next.path) if (plainPair && listTypes.includes(next.node.type)) {
} if (previous.node.type === next.node.type) {
if (container === 'list-item' && listTypes.includes(next.node.type)) { return failure('unspellable-adjacent-lists', `two adjacent ${next.node.type} nodes read back as one list`, next.path)
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) 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 (container !== 'directive' || plainPair) return success('\n\n')
if (previous.spelling === 'commonmark' && next.spelling === 'commonmark') 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,16 @@ 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}` }) if (directive.body === 'none' || (directive.body === 'inline' && content.length === 0)) {
return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` })
}
const body = directive.body === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1) 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)
@@ -124,42 +132,41 @@ function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: Conv
} }
function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Result<EmittedBody> { function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Result<EmittedBody> {
if (content.length === 0) return success({ fenceColons: 0, text: '' })
const line = emitInlineLine(content, 'paragraph', path) const line = emitInlineLine(content, 'paragraph', path)
if (!line.ok) return line if (!line.ok) return line
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 (!carriesOnly(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 (!carriesOnly(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,113 +184,74 @@ 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.
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 (/[`\\]/.test(language) || holdsControlCharacter(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 (!carriesOnly(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 (!carriesOnly(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.length === 0) return undefined
if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined
const lines: string[] = [] const lines: string[] = []
let fenceColons = 0 let fenceColons = 0
for (const [offset, item] of items.entries()) { for (const [offset, item] of items.entries()) {
const itemPath = [...path, 'content', offset] const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ', [...path, 'content', offset], depth)
if (item.type !== 'listItem') return failure('unsupported-node-shape', `a ${node.type} holds listItem nodes only`, itemPath) if (emitted === undefined) return undefined
const emitted = emitListItem(item, ordered ? `${start.value + 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> | undefined {
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() })
const indent = ' '.repeat(marker.length) const indent = ' '.repeat(marker.length)
const lines = inner.value.text.split('\n').map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`)) const lines = inner.value.text.split('\n').map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
const first = lines[0] ?? '' if (isThematicBreak(lines[0] ?? '')) return undefined
if (isThematicBreak(first)) {
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(first)}`, path)
}
return success({ fenceColons: inner.value.fenceColons, text: lines.join('\n') }) return success({ fenceColons: inner.value.fenceColons, text: lines.join('\n') })
} }
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> { 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 || !carriesOnly(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 (!carriesOnly(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> {
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)
} }
+23 -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,56 @@ 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 spelled = directive.argument === undefined ? spelledByBody : [directive.argument, ...spelledByBody]
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, spelled)
if (pairs === undefined) return undefined if (pairs === undefined) return undefined
const marks = node.marks ?? [] const marks = node.marks ?? []
if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))]) if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))])
+2 -2
View File
@@ -11,10 +11,10 @@ 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[]): [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 === undefined ? [] : [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)
} }
+4 -3
View File
@@ -1,4 +1,5 @@
import type { AdfNode } from './adf-document.ts' import type { AdfNode } from './adf-document.ts'
import { carriesOnly } from './adf-document.ts'
import type { ConvertErrorPath } from './result.ts' import type { ConvertErrorPath } from './result.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { serializeCanonicalJson } from './canonical-json.ts'
import { tryImageLine } from './markdown-inline.ts' import { tryImageLine } from './markdown-inline.ts'
@@ -15,12 +16,12 @@ export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefi
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined { function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
const content = node.content ?? [] const content = node.content ?? []
const media = content[0] const media = content[0]
if (serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle || (node.marks ?? []).length > 0) return undefined if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(node.attrs ?? {}, 'compact') !== centeredMediaSingle) return undefined
if (media === undefined || content.length !== 1 || media.type !== 'media' || (media.marks ?? []).length > 0 || (media.content ?? []).length > 0) return undefined if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || (media.content ?? []).length > 0) return undefined
const attrs = media.attrs ?? {} const attrs = media.attrs ?? {}
const alt = attrs['alt'] const alt = attrs['alt']
const url = attrs['url'] const url = attrs['url']
if (Object.keys(attrs).some((key) => !imageAttributes.includes(key)) || attrs['type'] !== 'external' || typeof url !== 'string') return undefined if (attrs['type'] !== 'external' || typeof url !== 'string') return undefined
if (alt !== undefined && (typeof alt !== 'string' || alt === '')) return undefined if (alt !== undefined && (typeof alt !== 'string' || alt === '')) return undefined
return { alt, url } return { alt, url }
} }
+11 -12
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, index, 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> {
@@ -215,9 +215,8 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n
return success({ segments: [syntax(`:${node.type}[`), ...content, syntax(`]${attributes}`)] }) return success({ segments: [syntax(`:${node.type}[`), ...content, syntax(`]${attributes}`)] })
} }
function emitText(node: AdfNode, context: InlineContext, path: ConvertErrorPath): Result<Emission> { function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
const unspelled = Object.keys(node.attrs ?? {})[0] if (Object.keys(node.attrs ?? {}).length > 0) return success({ carry: { first: index, last: index } })
if (unspelled !== undefined) return failure('unspelled-node-attribute', `the text attribute ${unspelled} has no canonical markdown spelling`, path)
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text', path) if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text', path)
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content', path) if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node holds no content', path)
if (/\r/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a carriage return CommonMark rewrites', path) if (/\r/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a carriage return CommonMark rewrites', path)
+5 -8
View File
@@ -1,4 +1,5 @@
import type { AdfNode } from './adf-document.ts' import type { AdfNode } from './adf-document.ts'
import { carriesOnly } from './adf-document.ts'
import { tryPipeCell } from './markdown-inline.ts' import { tryPipeCell } from './markdown-inline.ts'
import type { ConvertErrorPath } from './result.ts' import type { ConvertErrorPath } from './result.ts'
@@ -23,16 +24,16 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
function pipeRows(node: AdfNode): AdfNode[][] | undefined { function pipeRows(node: AdfNode): AdfNode[][] | undefined {
const rows = node.content ?? [] const rows = node.content ?? []
const columns = (rows[0]?.content ?? []).length const columns = (rows[0]?.content ?? []).length
if (!isPlain(node) || columns === 0) return undefined if (!carriesOnly(node, []) || columns === 0) return undefined
const grid: AdfNode[][] = [] const grid: AdfNode[][] = []
for (const [index, row] of rows.entries()) { for (const [index, row] of rows.entries()) {
const cells = row.content ?? [] const cells = row.content ?? []
if (row.type !== 'tableRow' || !isPlain(row) || cells.length !== columns) return undefined if (row.type !== 'tableRow' || !carriesOnly(row, []) || cells.length !== columns) return undefined
const wanted = index === 0 ? 'tableHeader' : 'tableCell' const wanted = index === 0 ? 'tableHeader' : 'tableCell'
const paragraphs: AdfNode[] = [] const paragraphs: AdfNode[] = []
for (const cell of cells) { for (const cell of cells) {
const paragraph = plainParagraph(cell) const paragraph = plainParagraph(cell)
if (paragraph === undefined || cell.type !== wanted || !isPlain(cell)) return undefined if (paragraph === undefined || cell.type !== wanted || !carriesOnly(cell, [])) return undefined
paragraphs.push(paragraph) paragraphs.push(paragraph)
} }
grid.push(paragraphs) grid.push(paragraphs)
@@ -40,13 +41,9 @@ function pipeRows(node: AdfNode): AdfNode[][] | undefined {
return grid return grid
} }
function isPlain(node: AdfNode): boolean {
return Object.keys(node.attrs ?? {}).length === 0 && (node.marks ?? []).length === 0 && node.text === undefined
}
function plainParagraph(cell: AdfNode): AdfNode | undefined { function plainParagraph(cell: AdfNode): AdfNode | undefined {
const content = cell.content ?? [] const content = cell.content ?? []
const paragraph = content[0] const paragraph = content[0]
if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !isPlain(paragraph)) return undefined if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !carriesOnly(paragraph, [])) return undefined
return paragraph return paragraph
} }
-6
View File
@@ -1,19 +1,13 @@
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'
| '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)[]
+24 -4
View File
@@ -55,7 +55,7 @@ detail is settled at its own milestone.
- [x] **1d3 — Inline nodes and marks**: date, emoji, inlineCard, mediaInline, mention, status; - [x] **1d3 — Inline nodes and marks**: date, emoji, inlineCard, mediaInline, mention, status;
border, subsup, textColor, underline; the content slot's `text` attribute and the border, subsup, textColor, underline; the content slot's `text` attribute and the
`:text{text="…"}` whitespace spelling. `:text{text="…"}` whitespace spelling.
- [ ] **2 — `adfToMarkdown`.** First real code. Each sub-item turns one corpus directory green; - [x] **2 — `adfToMarkdown`.** First real code. Each sub-item turns one corpus directory green;
the two that have no fixtures yet write them in the same chunk, tests first (§10). the two that have no fixtures yet write them in the same chunk, tests first (§10).
- [x] **2a — The runner and the CommonMark subset.** The corpus runner: walk - [x] **2a — The runner and the CommonMark subset.** The corpus runner: walk
`corpus/round-trip/`, assert `adfToMarkdown` emits each `.md` byte for byte. Decide here `corpus/round-trip/`, assert `adfToMarkdown` emits each `.md` byte for byte. Decide here
@@ -65,7 +65,10 @@ detail is settled at its own milestone.
the inline carry. `commonmark-subset/` green. the inline carry. `commonmark-subset/` green.
- [x] **2b — Block nodes.** `block-nodes/` green. A nested list that cannot interrupt the block - [x] **2b — Block nodes.** `block-nodes/` green. A nested list that cannot interrupt the block
above it is refused meanwhile, not spelled: the maintainer's answer on tight-versus-blank above it is refused meanwhile, not spelled: the maintainer's answer on tight-versus-blank
separation turns that refusal into an emission. Block separation becomes separation turns that refusal into an emission. The test is broader than the name it
carries — `interruptsParagraph` reads the next list alone, so a list after a block no
paragraph continues, a code block say, is refused too — and the same answer narrows it.
Block separation becomes
`separationBetween(previous, next, container)` here — a boolean cannot hold the third case `separationBetween(previous, next, container)` here — a boolean cannot hold the third case
`spec/flavour.md` states for two directive blocks in a container body, and the maintainer's `spec/flavour.md` states for two directive blocks in a container body, and the maintainer's
answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give
@@ -82,7 +85,7 @@ detail is settled at its own milestone.
info string, and the `codeBlock` whose language is `adf` — carried whole ahead of the info string, and the `codeBlock` whose language is `adf` — carried whole ahead of the
attribute fallback 2e owes, since the reservation leaves that node no other spelling attribute fallback 2e owes, since the reservation leaves that node no other spelling
whatever 1d decides for its `localId`. whatever 1d decides for its `localId`.
- [ ] **2e — Carve-outs and combinations.** Fixtures and emitter together, into - [x] **2e — Carve-outs and combinations.** Fixtures and emitter together, into
`corpus/round-trip/combinations/`. `corpus/round-trip/combinations/`.
- [x] **2e1 — The carve-outs and the claimed line.** The three carve-outs - [x] **2e1 — The carve-outs and the claimed line.** The three carve-outs
and their escapes, and a paragraph line inside a container body shaped like a closing and their escapes, and a paragraph line inside a container body shaped like a closing
@@ -124,7 +127,7 @@ detail is settled at its own milestone.
both ways keeps every delimiter the emitter did not write out of the matching. The both ways keeps every delimiter the emitter did not write out of the matching. The
canonical form gained a backslash where a run only closes — `\*not emphasis\*`, and canonical form gained a backslash where a run only closes — `\*not emphasis\*`, and
2e1's `carve-out-strike` a third and fourth. 2e1's `carve-out-strike` a third and fourth.
- [ ] **2f — The attributes CommonMark cannot hold.** 1d's settled answer: the block nodes - [x] **2f — The attributes CommonMark cannot hold.** 1d's settled answer: the block nodes
CommonMark spells — `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`, CommonMark spells — `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`,
`orderedList`, `paragraph`, `rule` — get directive sections in `spec/flavour.md` carrying `orderedList`, `paragraph`, `rule` — get directive sections in `spec/flavour.md` carrying
`localId`, `codeBlock`'s `hideLineNumbers`, `uniqueId` and `wrap`, and `blockquote`'s `localId`, `codeBlock`'s `hideLineNumbers`, `uniqueId` and `wrap`, and `blockquote`'s
@@ -134,6 +137,23 @@ 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.
2f raises what 1d's unspelled block separation costs: a single `localId` on a paragraph
beside a plain one now refuses every container body that is a directive's — a panel, an
expand, a table cell — where before 2f the attribute refused the document anyway.
- [ ] **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