5 Commits

86 changed files with 1475 additions and 321 deletions
+12 -2
View File
@@ -10,8 +10,14 @@ In `docs/decisions.md`:
- Plain markdown is a flavour of the grammar
- The round-trip is the product
- Markdown in is a canonical fixpoint
- Equality is editor-normal
- Equality is deep
- `!adf:textBreak{}` parts text CommonMark would join
- An empty key spells `empty`
- `-0` is spelled `-0`
- Empty markdown is a document of no blocks
- Unknown nodes ride the carry
- The carry fence names the node type
- A code block is a fence per text node
- Foreign HTML sorts three ways
- Names stay text
- Directives under `!adf:`
@@ -162,12 +168,16 @@ nearest text, a candidate entry in that file's voice, and the instance it yields
keeps collecting instances is wrong: rewrite it.
Which output the audience expects — README goal 5 — is settled by a reader panel rather than
asked: three fresh-context readers, one per README persona the conversion serves, each given only
asked: three fresh-context readers, one per README persona the question serves, each given only
`## Audience` and the input, writing what they expect before picking among outputs the goals
allow, rendered, shuffled, with no rationale and nothing saying what is implemented. Three agreeing
settle it; otherwise four more read, five of seven settle it, and less is a missing goal, asked.
The verdict lands in `docs/decisions.md`.
Every new or changed markdown or HTML spelling goes to such a panel, seated by the people who read
and write that format (README `## Audience`); a question about what an app relies on seats the
developer personas.
### Stated numbers
A stated number — 500 levels, the branch floor — is kept; a chunk that cannot keep it asks, naming
+14 -4
View File
@@ -2,10 +2,20 @@
## Unreleased
- **Breaking:** directives, the opaque carry among them (now `carry`), are spelled under an `!adf:`
prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`, `!adf:name arg {attrs}`) in place
of the `:::`/`::`/`:name` forms: text holding an unescaped `!adf:` is claimed, and `adf` is an
ordinary code block language. Convert stored markdown per `MIGRATION.md`.
- **Breaking:** directives, the inline opaque carry among them (now `!adf:carry{json="…"}`), are
spelled under an `!adf:` prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`,
`!adf:name arg {attrs}`) in place of the `:::`/`::`/`:name` forms, and the block carry is a code
fence whose info string `adf:<type>` names the node's type, its body the node's JSON without
`type`: text holding an unescaped `!adf:` and a code fence whose info string opens `adf:` are
claimed, and `adf` is an ordinary code block language. Convert stored markdown per `MIGRATION.md`.
- **Breaking:** `markdownToAdf` reads markdown holding no block as a document whose `content` is
empty, as Atlassian's schema requires; `!adf:doc {content=none}` spells a document holding no
`content` key.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc`: two adjacent text nodes a reader would
join are parted by `!adf:textBreak{}`, an empty `attrs`, `content` or `marks` is spelled
`{attrs=empty}`, `{content=empty}` or `{marks=empty}`, `-0` is spelled `-0`, and a `codeBlock`
of several text nodes is a fence per node. A `codeBlock` holding other than plain text nodes
rides the block carry, where it was refused.
- **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no
CommonMark escape spells is written as `!adf:link[text]{attrs}`.
- **Breaking:** some directive refusals carry `malformed-directive` where they carried
+5 -2
View File
@@ -40,12 +40,12 @@ function migrateMarkdown(stored: string) {
| `::media {id=a type=file}` | `!adf:media {id=a type=file}` |
| `::taskItem TODO {localId=i}`: an empty `caption`, `decisionItem`, `paragraph` or `taskItem`, or an empty `heading` carrying `localId` | `!adf:taskItem TODO {localId=i}` then `!adf:/taskItem` |
| `:mention[@Mikael]{id=5b10a2}` | `!adf:mention[@Mikael]{id=5b10a2}` |
| the `adf` code fence and `:adf{json="…"}` | the `carry` code fence and `!adf:carry{json="…"}` |
| the `adf` code fence and `:adf{json="…"}` | the `adf:<type>` code fence, its JSON without `type`, and `!adf:carry{json="…"}` |
| `\:` keeps a directive literal | `\!adf:` keeps a directive literal |
| `:adf{json="…"}` carrying a link for its `collection`, `id` or `occurrenceKey` | `!adf:link[text]{attrs}` |
A colon run and `:name[` are plain text now, and `adf` an ordinary code block language; text
holding an unescaped `!adf:` and a `carry` fence are claimed instead.
holding an unescaped `!adf:` and a code fence whose info string opens `adf:` are claimed instead.
### Readings
@@ -54,6 +54,8 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
| Input | `0.1.0` | `0.2.0` |
| --- | --- | --- |
| a link whose text already holds one (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them |
| markdown holding no block (`markdownToAdf("")`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former |
| a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | the block carry; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block |
### Error codes
@@ -63,6 +65,7 @@ it named converts.
| Input | `0.1.0` | `0.2.0` |
| --- | --- | --- |
| a link whose `href` or `title` no CommonMark escape spells, on emit | `unspellable-link` | spells `!adf:link[text]{attrs}` |
| a `codeBlock` holding other than plain text nodes, on emit | `unsupported-node-shape` | rides the block carry |
| a leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` |
| a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` |
| an empty node the `::taskItem` spelling row names, written as a leaf | parses | `malformed-directive` |
+9 -3
View File
@@ -52,6 +52,10 @@ which is free text.
- **LLM/agent pipeline** — hands documents to a model as markdown and writes the edits back.
Relies on the round-trip and on markdown a reader half-knowing the lossless flavour can still edit.
Behind those apps, the people who read and write the markdown, and later the HTML: product
managers, engineers and support agents working in Atlassian products through a plugin or another
UI. They know markdown and not ADF, and rely on every spelling saying what it means to them.
## The shape
```sh
@@ -171,7 +175,7 @@ Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.2.0`
| Code | Fires when | What you can do |
| --- | --- | --- |
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in a `carry` | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text |
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in an opaque carry | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text |
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
| `unmappable-html` | the input holds an HTML construct the documented element set does not map, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown, the element set landing at `0.2.0` | remove the construct, or write what it holds in the lossless flavour |
@@ -200,7 +204,9 @@ emit refuses:
Serves Goals 1, 3 and 4.
- `markdownToAdf(adfToMarkdown(doc))` equals `doc` — unknown node types included, carried opaquely
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` — every key and value as `doc` holds it,
adjacent text nodes, an empty `attrs`, `content` or `marks` and `-0` included, and unknown node
types carried opaquely
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
- Markdown this library reads, and markdown it writes, means what the CommonMark spec says; from
`0.2.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below
@@ -235,7 +241,7 @@ Serves Goals 1, 3 and 4.
makes a call loop forever.
- The emitted formats are semver surface
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#the-formats-are-api)).
- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides
- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` deep-equals `doc`; fidelity HTML cannot express rides
`data-*` attributes. Foreign HTML maps a documented element set, which markdown's raw HTML reads
through as well, and a construct outside it is an error; well-formed HTML only — no tag-soup
recovery.
+6 -3
View File
@@ -1,16 +1,19 @@
try {
const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js')
const { serializeCanonicalJson } = await import('/dist/canonical-json.js')
// WebDriver's JSON reads -0 back as 0, so a document crosses as its canonical spelling.
const spelled = (result) => (result.ok ? { ok: true, value: `${serializeCanonicalJson(result.value, 'two-space')}\n` } : result)
window.convertCorpus = (corpus) => ({
errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: spelled(markdownToAdf(markdown)) })),
realPayloads: corpus.realPayloads.map(({ json, name }) => {
const adf = JSON.parse(json)
const emitted = adfToMarkdown(adf)
return { emitted, isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? markdownToAdf(emitted.value) : undefined }
return { emitted, isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? spelled(markdownToAdf(emitted.value)) : undefined }
}),
roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => {
const adf = JSON.parse(json)
return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: markdownToAdf(markdown) }
return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: spelled(markdownToAdf(markdown)) }
}),
})
} catch (cause) {
+3 -4
View File
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict'
import { createServer } from 'node:http'
import { extname, join } from 'node:path'
import { readFileSync, readdirSync } from 'node:fs'
import { toEditorNormal } from '../dist/adf/editor-normal.js'
const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' }
const driver = 'http://127.0.0.1:4444'
@@ -107,7 +106,7 @@ for (const [index, { json, markdown, name }] of corpus.roundTrip.entries()) {
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
assert.equal(result.emitted.value, markdown)
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(json))
assert.equal(result.parsed.value, json)
})
}
@@ -115,7 +114,7 @@ for (const [index, { name }] of corpus.normalization.entries()) {
const result = results.normalization[index]
checking(name, result, () => {
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(fixture(name, '.json')))
assert.equal(result.parsed.value, fixture(name, '.json'))
})
}
@@ -125,7 +124,7 @@ for (const [index, { json, name }] of corpus.realPayloads.entries()) {
assert.ok(result.isDocument, `${name}.json is no ADF document`)
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
assert.ok(result.parsed.ok, `it did not parse back — ${refusal(result.parsed)}`)
assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(json))
assert.equal(result.parsed.value, json)
})
}
+2 -2
View File
@@ -18,8 +18,8 @@ One directory per contract kind:
`reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-text-node
model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap).
JSON is editor-normal (`docs/decisions.md` §Equality is editor-normal), two-space indent, keys
sorted. `spec.json` is the vendored, upstream machine-readable suite, byte-exact from
JSON is two-space indent, keys sorted, and a document read back must deep-equal the fixture's
(`docs/decisions.md` §Equality is deep). `spec.json` is the vendored, upstream machine-readable suite, byte-exact from
[spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John
MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not
re-serialized by the corpus gate.
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,5 @@
```adf:
{
"type": "blockCard"
}
```
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+5
View File
@@ -0,0 +1,5 @@
```adf:blockCard
{
"type": "blockCard"
}
```
+2 -2
View File
@@ -1,3 +1,3 @@
```carry
{"type":
```adf:blockCard
{"attrs":
```
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,8 @@
!adf:codeBlock
```js
a
```
```ts
b
```
!adf:/codeBlock
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
!adf:doc
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,3 @@
!adf:doc {content=none}
Text.
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,3 @@
!adf:panel info {attrs=empty}
Text.
!adf:/panel
@@ -0,0 +1 @@
unsupported-node-shape
+3
View File
@@ -0,0 +1,3 @@
!adf:paragraph {content=empty}
Text.
!adf:/paragraph
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+3
View File
@@ -0,0 +1,3 @@
!adf:paragraph {attrs=none}
Text.
!adf:/paragraph
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
**!adf:date{marks=empty}**
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
a!adf:textBreak{x=y}b
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
a!adf:textBreak[x]b
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
**a**!adf:textBreak{}b
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
!adf:textBreak{}Hello
@@ -14,7 +14,19 @@
},
{
"attrs": {
"language": "carry"
"language": "adf:blockCard"
},
"content": [
{
"text": "{\n \"attrs\": {}\n}",
"type": "text"
}
],
"type": "codeBlock"
},
{
"attrs": {
"language": "adf:"
},
"content": [
{
@@ -1,12 +1,18 @@
!adf:codeBlock {language=carry}
```
```carry
{
"type": "blockCard"
}
```
!adf:codeBlock {language="adf:blockCard"}
```
{
"attrs": {}
}
```
!adf:/codeBlock
!adf:codeBlock {language=carry}
!adf:codeBlock {language="adf:"}
````
```
````
@@ -0,0 +1,56 @@
{
"content": [
{
"attrs": {
"language": "js"
},
"content": [
{
"text": "const a = 1\n",
"type": "text"
},
{
"text": "const b = 2",
"type": "text"
}
],
"type": "codeBlock"
},
{
"content": [
{
"text": "a",
"type": "text"
},
{
"text": "```",
"type": "text"
},
{
"text": "\n",
"type": "text"
}
],
"type": "codeBlock"
},
{
"attrs": {
"language": "has`tick",
"wrap": true
},
"content": [
{
"text": "x",
"type": "text"
},
{
"text": " y ",
"type": "text"
}
],
"type": "codeBlock"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,31 @@
!adf:codeBlock
```js
const a = 1
```
```js
const b = 2
```
!adf:/codeBlock
!adf:codeBlock
```
a
```
````
```
````
```
```
!adf:/codeBlock
!adf:codeBlock {language="has\u0060tick" wrap=true}
```
x
```
```
y
```
!adf:/codeBlock
@@ -0,0 +1,4 @@
{
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
!adf:doc {content=none}
@@ -0,0 +1,89 @@
{
"content": [
{
"content": [],
"type": "paragraph"
},
{
"attrs": {},
"content": [
{
"text": "Plain words.",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [],
"type": "rule"
},
{
"attrs": {
"level": 2
},
"content": [
{
"text": "Title",
"type": "text"
}
],
"marks": [],
"type": "heading"
},
{
"attrs": {
"panelType": "info"
},
"content": [],
"type": "panel"
},
{
"content": [
{
"attrs": {},
"content": [
{
"content": [
{
"text": "Item",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
},
{
"content": [
{
"content": [
{
"text": "Next",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "bulletList"
},
{
"content": [],
"type": "codeBlock"
},
{
"attrs": {
"language": "js"
},
"marks": [],
"type": "codeBlock"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,32 @@
!adf:paragraph {content=empty}
!adf:/paragraph
!adf:paragraph {attrs=empty}
Plain words.
!adf:/paragraph
!adf:rule {content=empty}
!adf:heading {level=2 marks=empty}
Title
!adf:/heading
!adf:panel info {content=empty}
!adf:/panel
!adf:bulletList
!adf:listItem {attrs=empty}
Item
!adf:/listItem
!adf:listItem
Next
!adf:/listItem
!adf:/bulletList
!adf:codeBlock {content=empty}
!adf:/codeBlock
!adf:codeBlock {marks=empty}
```js
```
!adf:/codeBlock
@@ -1,4 +1,4 @@
```carry
```adf:panel
{
"attrs": {
"rounded": true
@@ -13,12 +13,11 @@
],
"type": "paragraph"
}
],
"type": "panel"
]
}
```
```carry
```adf:panel
{
"attrs": {
"panelType": "extra info"
@@ -33,8 +32,7 @@
],
"type": "paragraph"
}
],
"type": "panel"
]
}
```
@@ -0,0 +1,83 @@
{
"content": [
{
"attrs": {
"order": -0
},
"content": [
{
"content": [
{
"content": [
{
"text": "First",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "orderedList"
},
{
"attrs": {
"level": -0
},
"content": [
{
"text": "Title",
"type": "text"
}
],
"type": "heading"
},
{
"attrs": {
"extensionKey": "toc",
"extensionType": "com.atlassian.confluence.macro.core",
"parameters": {
"maxLevel": -0
}
},
"type": "extension"
},
{
"content": [
{
"attrs": {
"data": [
-0
],
"url": "https://example.com/"
},
"type": "inlineCard"
},
{
"marks": [
{
"attrs": {
"color": "#000000",
"size": -0
},
"type": "border"
}
],
"text": "x",
"type": "text"
}
],
"type": "paragraph"
},
{
"attrs": {
"x": -0
},
"type": "blockCard"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,21 @@
!adf:orderedList {order=-0}
!adf:listItem
First
!adf:/listItem
!adf:/orderedList
!adf:heading {level=-0}
Title
!adf:/heading
!adf:extension {extensionKey=toc extensionType="com.atlassian.confluence.macro.core" parameters="{\"maxLevel\":-0}"}
!adf:inlineCard{data="[-0]" url="https://example.com/"}!adf:border[x]{color="#000000" size=-0}
```adf:blockCard
{
"attrs": {
"x": -0
}
}
```
@@ -1,4 +1,5 @@
{
"content": [],
"type": "doc",
"version": 1
}
@@ -0,0 +1,98 @@
{
"content": [
{
"content": [
{
"attrs": {},
"type": "date"
},
{
"text": " ",
"type": "text"
},
{
"content": [],
"type": "date"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "a",
"type": "text"
},
{
"marks": [],
"type": "hardBreak"
},
{
"text": "b",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"attrs": {
"id": "x",
"text": "@M"
},
"content": [],
"type": "mention"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "bare",
"type": "text"
},
{
"marks": [],
"text": "marked",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"attrs": {},
"type": "em"
}
],
"text": "x",
"type": "text"
},
{
"text": " and ",
"type": "text"
},
{
"attrs": {
"shortName": ":a:"
},
"marks": [
{
"attrs": {},
"type": "strong"
}
],
"type": "emoji"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,9 @@
!adf:date{attrs=empty} !adf:date{content=empty}
a!adf:hardBreak{marks=empty}b
!adf:mention[@M]{content=empty id=x}
bare!adf:carry{json="{\"marks\":[],\"text\":\"marked\",\"type\":\"text\"}"}
!adf:carry{json="{\"marks\":[{\"attrs\":{},\"type\":\"em\"}],\"text\":\"x\",\"type\":\"text\"}"} and !adf:carry{json="{\"attrs\":{\"shortName\":\":a:\"},\"marks\":[{\"attrs\":{},\"type\":\"strong\"}],\"type\":\"emoji\"}"}
@@ -0,0 +1,154 @@
{
"content": [
{
"content": [
{
"text": "Hello, ",
"type": "text"
},
{
"text": "world",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "strong"
}
],
"text": "Hello, ",
"type": "text"
},
{
"marks": [
{
"type": "strong"
}
],
"text": "world",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "code"
}
],
"text": "a",
"type": "text"
},
{
"marks": [
{
"type": "code"
}
],
"text": "b",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"attrs": {
"href": "https://example.com/"
},
"type": "link"
}
],
"text": "a",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/"
},
"type": "link"
}
],
"text": "b",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "Line\n",
"type": "text"
},
{
"text": "next",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "underline"
}
],
"text": "a",
"type": "text"
},
{
"marks": [
{
"type": "underline"
}
],
"text": "b",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"attrs": {},
"type": "underline"
}
],
"text": "a",
"type": "text"
},
{
"marks": [
{
"type": "underline"
}
],
"text": "b",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,13 @@
Hello, !adf:textBreak{}world
**Hello, !adf:textBreak{}world**
`a`!adf:textBreak{}`b`
[a!adf:textBreak{}b](https://example.com/)
Line!adf:text{text="\n"}!adf:textBreak{}next
!adf:underline[a!adf:textBreak{}b]
!adf:underline[a]{attrs=empty}!adf:underline[b]
@@ -1,17 +1,15 @@
> ```carry
> ```adf:blockCard
> {
> "attrs": {
> "url": "https://example.com/quoted"
> },
> "type": "blockCard"
> }
> }
> ```
- ```carry
- ```adf:blockCard
{
"attrs": {
"url": "https://example.com/listed"
},
"type": "blockCard"
}
}
```
@@ -0,0 +1,35 @@
{
"content": [
{
"attrs": {
"language": "js"
},
"content": [
{
"marks": [
{
"type": "strong"
}
],
"text": "const a = 1",
"type": "text"
}
],
"type": "codeBlock"
},
{
"content": [
{
"text": "a",
"type": "text"
},
{
"type": "hardBreak"
}
],
"type": "codeBlock"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,32 @@
```adf:codeBlock
{
"attrs": {
"language": "js"
},
"content": [
{
"marks": [
{
"type": "strong"
}
],
"text": "const a = 1",
"type": "text"
}
]
}
```
```adf:codeBlock
{
"content": [
{
"text": "a",
"type": "text"
},
{
"type": "hardBreak"
}
]
}
```
@@ -1,23 +1,21 @@
```carry
```adf:blockCard
{
"attrs": {
"url": "https://example.com/roadmap"
},
"type": "blockCard"
}
}
```
!adf:panel info
The card below has no spelling yet.
```carry
```adf:embedCard
{
"attrs": {
"layout": "wide",
"url": "https://example.com/board",
"width": 100
},
"type": "embedCard"
}
}
```
!adf:/panel
@@ -0,0 +1,21 @@
{
"content": [
{
"attrs": {
"url": "https://example.com/"
},
"type": "has`tick"
},
{
"type": ""
},
{
"type": " padded"
},
{
"type": "two words"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,24 @@
```adf:
{
"attrs": {
"url": "https://example.com/"
},
"type": "has`tick"
}
```
```adf:
{
"type": ""
}
```
```adf:
{
"type": " padded"
}
```
```adf:two words
{}
```
+90 -16
View File
@@ -14,7 +14,7 @@ a backslash reach them intact; what the flavour cannot spell reduces ADF→ADF a
2026-08-23, real payloads 2026-09-15, the maintainer. Goal 1. Valid while a consumer saves back
through the lossless pair.
`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc` — anything
`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must deep-equal `doc` — anything
less silently destroys content an editor could not represent, in a document it did not author.
When losslessness and readability conflict, losslessness wins. Round-trip equality is a property
tested over a checked-in corpus (`corpus/README.md`), not a claim made in prose. Its real payloads
@@ -31,27 +31,100 @@ where there is a way back. CommonMark spells some things the flavour has no esca
paragraph opening with a code span whose backticks read back as a fence — so a parse succeeding
does not imply a spellable document; `corpus/commonmark-spec/exceptions.json` names those.
## Equality is editor-normal
## Equality is deep
2026-08-24, the maintainer. Goal 1. Valid while markdown cannot tell apart the ADF shapes this
merges.
2026-08-24, deep 2026-10-03, the maintainer. Goal 1. Valid while a pipeline or a bot can build a
shape the editor would not.
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
and no attributes merged, JSON number semantics, an empty attrs object, marks array or content
array the absent key — the only domain markdown can restore.
`todo.md` item 40 replaces this with deep equality (2026-09-28, the maintainer).
"Equals" is `assert.deepStrictEqual`: every key and value `doc` holds, two adjacent text nodes, an
empty `attrs`, `content` or `marks` and `-0` included, with no normalization on either side.
CommonMark's spelling stays wherever a document holds none of those shapes. The plain flavour is
lossy and stays editor-normal: its reduction reads and writes ADF with adjacent text nodes of
identical marks and no attributes merged, `-0` read as `0`, and an empty `attrs`, `content` or
`marks` the absent key.
## `!adf:textBreak{}` parts text CommonMark would join
2026-10-03, the maintainer. Goal 1. Valid while CommonMark reads adjacent text as one run.
Two adjacent text nodes a reader would build back as one — neither carried, neither holding
`attrs` or an empty key, their marks identical, attributes included — are parted by the reserved
inline leaf `!adf:textBreak{}`, mirroring `!adf:listBreak`: it builds no node, and anywhere but
between two such nodes, or given `[content]` or `{attrs}`, it is `unsupported-node-shape`. It sits
inside every mark spelling the pair shares, `**Hello, !adf:textBreak{}world**`; a code span holds
no directive, so it closes and reopens, `` `a`!adf:textBreak{}`b` ``. A carried node never joins
its neighbour, so a carry needs no break. A mark run breaks on any difference in the mark, `attrs:
{}` against no `attrs` included.
## An empty key spells `empty`
2026-10-03, a writer panel and the maintainer. Goals 1 and 5. Valid while no attribute value
spells an empty object or array.
An `attrs`, `content` or `marks` key holding an empty object or array is the reserved key with the
bare value `empty` on every directive — block, inline node and mark: `{attrs=empty}`,
`{content=empty}`, `{marks=empty}`. A writer panel chose the spelling, 5 of 7. A container
spelling `content=empty` closes with no body, so an empty pair stays the node holding no content
key; a leaf spells it too, `!adf:rule {content=empty}`. A node CommonMark spells takes its
directive form to hold an empty key, and a text node holding one rides the inline carry, as does a
node under an `em`, `strong`, `strike`, `code` or `link` mark whose `attrs` is empty, since none of
those spellings holds attributes. Any other value of a reserved key is `unsupported-node-shape`,
except on a block's `marks`, which reads it as the marks array in JSON.
## `-0` is spelled `-0`
2026-10-03, the maintainer. Goal 1. Valid while JSON's own serialization writes `-0` as `0`.
Wherever the flavour writes a number or a JSON value — an attribute, a `json` value, the carry —
`-0` is `-0`, which JSON's grammar reads back as `-0`. An `orderedList` whose `order` is `-0` takes
the directive form, since no list marker spells the sign.
## Empty markdown is a document of no blocks
2026-10-03, a panel and the maintainer. Goals 1 and 5. Valid while ADF's schema requires
`content` on `doc`.
Markdown holding no block reads as `{ content: [], type: 'doc', version: 1 }`, the document
`spec/adf-schema/full.json` requires. A document holding no `content` key is
`!adf:doc {content=none}` standing alone as its only block, and a named error anywhere else. The
writer panel split 4 for `none` and 3 for `absent`, and the maintainer chose `none`; all seven rejected a
bare `!adf:doc`.
## Unknown nodes ride the carry
2026-08-23, extended to misplaced known nodes 2026-08-26, the maintainer. Goal 1. Valid while ADF
holds nodes, or node positions, this library does not spell.
2026-08-23, extended to misplaced known nodes 2026-08-26 and to code block children 2026-10-03, the
maintainer. Goal 1. Valid while ADF holds nodes, or node positions, this library does not spell.
An unknown ADF node is carried opaquely — raw JSON rides a dedicated syntax in both formats and
restores to a deep-equal node. The round-trip holds for documents newer than the library. So does
a known node no section spells where it stands: a markdown serializer spells a node by type without
checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`.
Where a container's own spelling cannot hold the child it has — a `bulletList` holding other than
`listItem`, a `codeBlock` other than text — the error result names that instead.
checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`. A
`codeBlock` holding a child no fence holds — anything but a text node carrying no marks, `attrs` or
`content` — rides the carry whole.
## The carry fence names the node type
2026-10-03, the maintainer. Goals 1 and 5. Valid while a code fence's info string reads back
verbatim.
The block carry is a code fence whose info string `adf:<type>` names the node's type, its body the
node's JSON without `type`: ```` ```adf:blockCard ````. A type no info string carries back — by the
rule a code language follows — leaves the info string `adf:` and keeps `type` in the body. Every
info string opening `adf:` is reserved, so a `codeBlock` whose language opens so takes the
`language` attribute, and `carry` is an ordinary language. A body holding `type` under a named
type, or an `adf:` fence whose type an info string carries, is `unsupported-node-shape`.
## A code block is a fence per text node
2026-10-03, the maintainer. Goal 1. Valid while ADF holds a code block's text in more than one
node.
A `codeBlock` holding several text nodes is the `!adf:codeBlock` container holding one fence per
node, each fence's info string the language; its other attributes sit on the opener, and a
language no info string carries stays the opener's `language` with bare fences. A `codeBlock`
spelling `content=empty` has no fence to carry the language, so the opener does. Fences with
differing info strings are `unsupported-node-shape` — ADF holds one language — and so is an empty
fence beside another, since a text node holds text.
## Foreign HTML sorts three ways
@@ -295,10 +368,11 @@ handles one cause alike whichever node, attribute or direction raised it.
- A directive whose name reads back to no node is `unknown-directive-name` rather than a claim
code — the spelling is well formed, and telling that apart from a typo is what a consumer
switches on when a later MINOR gives the name meaning. A reserved name is a known name, so never
that code, and the two the flavour reserves part on form: a form the grammar does not have is a
claim code — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place
that code, and the names the flavour reserves part on form: a form the grammar does not have is
a claim code — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place
is `unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one
type (2026-09-01).
type, `!adf:textBreak{}` anything but two text nodes a reader joins, `!adf:doc` standing beside
another block (2026-09-01, the text break and `doc` 2026-10-03).
- A well-formed directive the node tables refuse — an attribute a node does not hold or spells
elsewhere, a value outside its kind or its canonical spelling, an argument, or a body of a shape
its content model does not take — is `unsupported-node-shape`, the emitter's code for the same
+66 -36
View File
@@ -44,7 +44,8 @@ normalizes to it through the round-trip.
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 an `!adf:paragraph` … `!adf:/paragraph` pair
holding nothing.
holding nothing, and one whose `content` is an empty array the pair
`!adf:paragraph {content=empty}` … `!adf:/paragraph` (Attributes).
- Links `[text](url)`; `<…>` around a destination containing spaces, `<>` an empty one beside a
title; title in double quotes. A backslash escapes a parenthesis the destination leaves
unbalanced, and a quote inside the title; a balanced pair stays bare. `<url>` autolink form only
@@ -67,7 +68,10 @@ normalizes to it through the round-trip.
matching below, which is what lets the emitter decide its own pairings.
- Blocks separated by one blank line at document level, inside a blockquote and between CommonMark
blocks; inside a directive container a pair holding a directive block takes none. No trailing
whitespace outside a code block's content, single trailing newline; a document with no blocks is the empty string.
whitespace outside a code block's content, single trailing newline. A document whose `content` is
an empty array is the empty string, and markdown holding no block reads back to it; a document
holding no `content` key is the leaf `!adf:doc {content=none}` as its only block, which is a
named error anywhere else or spelled any other way.
## Directives
@@ -79,9 +83,11 @@ result naming it at the opener, whatever follows it — so output an old emitter
escaped, and erroring input gaining meaning later is MINOR, never a reparse (`docs/decisions.md`
§The formats are API). Each name belongs to one position, and a name the other one spells — a mark
or an inline node written as a block directive, a block node written inline — is a different error,
naming the spelling it takes. Two reserved names read back to no node: `carry` for the opaque carry,
as both directive name and fence info string, and `listBreak` for the leaf that parts two adjacent
lists (Canonical form).
naming the spelling it takes. Four reserved names read back to no node: `carry` for the inline
opaque carry, `listBreak` for the leaf that parts two adjacent lists and `doc` for a document
holding no `content` key (Canonical form), and `textBreak` for the leaf that parts two text nodes
(Inline nodes). Every fence info string opening `adf:` is reserved for the block carry (The opaque
carry).
**Claiming**: an unescaped `!adf:` claims wherever it stands. What follows picks the form: `/name`
closes a container, and a name picks by what follows it in turn — a space or the line's end a block
@@ -142,6 +148,13 @@ ends the name (`!adf:hardBreak{}`). Input reads that spelling alone: keys out of
quoted where bare carries it, an escape longer than it need be, an empty `{attrs}` on a block line
or after a `[content]`, and a number or `json` value outside its canonical JSON spelling are each a
named error naming the spelling to write instead.
`attrs`, `content` and `marks` are reserved keys on every directive — block, inline node and mark —
whose bare value `empty` spells the node's or mark's key holding an empty object or array:
`!adf:underline[a]{attrs=empty}`, `!adf:date{content=empty}`, `!adf:hardBreak{marks=empty}`. A
container spelling `content=empty` closes with no body; `attrs=empty` stands beside no other
attribute, argument or content slot; and an inline node spelling `marks=empty` stands inside no
mark spelling. Any other value of a reserved key is a named error, except on a block's `marks`
(Block nodes).
**Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as
directive syntax — every literal `!adf:`, `]` inside content, a bracket a link's destination and
@@ -163,15 +176,18 @@ and restores to a deep-equal node. A carry may hold a node the emitter spells na
unreinterpreted, and the next emit spells it canonically (`docs/decisions.md` §The round-trip is the
product). Block and inline positions canonicalize differently, each fitting where it sits:
- **Block position**: a fenced code block with info string `carry`, body = the node's JSON —
two-space indent, object keys sorted.
- **Block position**: a fenced code block with info string `adf:` and the node's type, body = the
node's JSON without its `type` — two-space indent, object keys sorted: ```` ```adf:blockCard ````.
A type no info string carries back, by the rule a `codeBlock`'s language follows, leaves the info
string `adf:` and keeps `type` in the body. A body holding `type` under a named type, or an
`adf:` fence whose type an info string carries, is a named error.
- **Inline position**: `!adf:carry{json="…"}` — compact serialization (keys sorted, no whitespace),
JSON-string-escaped into the attribute.
The info string `carry` is reserved: a genuine `codeBlock` whose `language` is exactly `carry` takes
Every info string opening `adf:` is reserved: a genuine `codeBlock` whose `language` opens so takes
the attribute the section below keeps for a language no info string holds, so the reservation
stays absolute.
In block-directive position `!adf:carry` is a named error — the carry's block form is the fence.
stays absolute. In block-directive position `!adf:carry` is a named error — the carry's block form
is the fence.
## Raw HTML in input
@@ -193,13 +209,14 @@ Each section lists attributes as `name (type)`. A parenthesized value set docume
payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by
type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare
(`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys sorted),
quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty; editor-normal
ADF reads an empty attrs object, marks array or content array as the absent key (`docs/decisions.md`
§Equality is editor-normal) — the grammar's empty-`{attrs}` omission already collapses the two
spellings.
quoted, `-0` spelled `-0`. `markdownToAdf` builds an `attrs`, `content` or `marks` key only where the
markdown spells one, an empty one through its reserved key (Attributes), so a document reads back
deep-equal (`docs/decisions.md` §Equality is deep). A node CommonMark spells takes the directive
form to hold an empty key.
Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a
`json` value: `!adf:layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`.
`json` value, `marks=empty` where it is empty:
`!adf:layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`.
A section saying its body is inline takes at most one paragraph, whose inline content becomes
the node's `content`; any other body is a named error, and an empty pair is a node holding none.
@@ -215,14 +232,17 @@ cannot — `localId` (string) on any of them, marks, and the values below — ta
form.
- `blockquote`, `bulletList`, `listItem` — containers, block body. Attributes: `localId` (string).
- `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`
(string), `uniqueId` (string), `wrap` (boolean). A language no info string carries back — empty,
the reserved `carry`, 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 it in the slot that rule leaves empty, or in both, is a named error. The body is
one ordinary code block, and a fence's info string decodes escapes and entity references as any
other does.
- `codeBlock` — container, body one fenced code block per text node, each fence's info string the
language and its content the node's text; a node holding no `content` key is one empty fence.
Attributes: `hideLineNumbers` (boolean), `language` (string), `localId` (string), `uniqueId`
(string), `wrap` (boolean). A language no info string carries back — empty, opening 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 fences carry no info string, and so
does a language beside `content=empty`, which has no fence; writing it in the slot that rule
leaves empty, or in both, is a named error, and so are fences whose info strings differ and an
empty fence beside another. Each fence is an ordinary code block, and its info string decodes
escapes and entity references as any other does. A node holding a child no fence holds — any but
a text node carrying no marks, `attrs` or `content` — rides the block carry.
- `heading` — container, inline body. Attributes: `level` (number), `localId` (string). `level` is
the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no
CommonMark spelling.
@@ -424,9 +444,8 @@ Right.
Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of
the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot as
plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
parsing to anything but one text node carrying neither marks, attributes nor content — adjacent text
nodes with identical marks and no attributes merged first — is a named error, and so is a `text` key
in `{attrs}`. An enclosing mark spelling does not reach into the slot. The rest take no content,
parsing to anything but one text node carrying neither marks, attributes nor content is a named
error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the slot. The rest take no content,
`!adf:text` included; content on a node that takes none is a named error.
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
@@ -453,16 +472,27 @@ Shipped !adf:emoji[🎉]{shortName=":tada:"} on !adf:date{timestamp=175608000000
CommonMark strips or refuses one — a block's inline content edges, either side of a line break, an
em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled `!adf:text{text="…"}`,
the reserved key carrying the node's text, escaped by the attribute grammar and never literal: pipe
cells trim and pad. The emitter wraps the whitespace run alone and leaves the rest plain text;
`markdownToAdf` merges adjacent text nodes carrying identical marks and no attributes
(`docs/decisions.md` §Equality is editor-normal). Input reads that spelling alone: the value is one
run of spaces and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark
carries plainly — is a named error.
cells trim and pad. The emitter wraps the whitespace run alone and leaves the rest plain text, which
the spelled run joins on reading. Input reads that spelling alone: the value is one run of spaces
and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries
plainly — is a named error.
```
!adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines.
```
**Adjacent text nodes.** CommonMark reads two adjacent text nodes back as one where neither is
carried, neither holds `attrs` or an empty key, and their marks are identical, attributes included.
The reserved leaf `!adf:textBreak{}` parts such a pair, inside every mark spelling the two share; a
code span holds no directive, so it closes and reopens. It builds no node and reads only between
two such nodes: elsewhere, or with `[content]` or `{attrs}`, it is a named error (`docs/decisions.md`
§`!adf:textBreak{}` parts text CommonMark would join). A text node holding `attrs` or an empty key
rides the inline carry.
```
Hello, !adf:textBreak{}world — **Hello, !adf:textBreak{}world** — `a`!adf:textBreak{}`b`
```
## Marks
An inline node's marks ride the spelling wrapped around them, never the block sections' reserved
@@ -490,14 +520,14 @@ the directive form, open to no literal reading, is a named error.
A spelling adds its mark to every inline node it wraps, and nesting is the marks array in order,
outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the
reverse. `adfToMarkdown` nests in the order the array holds rather than sorting it —
`docs/decisions.md` §Equality is editor-normal restores the array, not a set — and opens each
spelling once over the longest run of adjacent inline nodes carrying an identical mark, attributes
included, at that depth. A run breaks at every node the emitter carries, so no emitted carry sits
inside a mark spelling.
`docs/decisions.md` §Equality is deep restores the array, not a set — and opens each spelling once
over the longest run of adjacent inline nodes carrying an identical mark, attributes included, at
that depth: `attrs: {}` differs from no `attrs`, and a directive spells it `{attrs=empty}`. A run
breaks at every node the emitter carries, so no emitted carry sits inside a mark spelling.
An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs
and the mark lacks, an order putting a code span outside another mark, `code` over anything but a
and the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside another mark, `code` over anything but a
text node or over text holding a newline, or a spelling CommonMark's flanking rules cannot open or
close where the run sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere — the
intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the
+27 -1
View File
@@ -1,6 +1,7 @@
import type { ConvertFault } from '../result.ts'
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
import { largestNesting } from '../nesting.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
export type AdfAttributes = { [key: string]: JsonValue }
@@ -17,6 +18,8 @@ export type AdfNode = {
type: string
}
export type EmptyKey = 'attrs' | 'content' | 'marks'
export type AdfDocument = {
content?: AdfNode[]
type: 'doc'
@@ -49,10 +52,26 @@ export function attributeNestingMessage(key: string, type: string): string {
}
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
if (nodeMarks(node).length > 0 || node.text !== undefined) return false
if (nodeMarks(node).length > 0 || node.text !== undefined || emptyKeys(node).length > 0) return false
return holdsOnly(nodeAttrs(node), attributes)
}
export function emptyKeys(held: { attrs?: AdfAttributes; content?: AdfNode[]; marks?: AdfMark[] }): EmptyKey[] {
const keys: EmptyKey[] = []
if (held.attrs !== undefined && Object.keys(held.attrs).length === 0) keys.push('attrs')
if (held.content?.length === 0) keys.push('content')
if (held.marks?.length === 0) keys.push('marks')
return keys
}
export function identicalMark(left: AdfMark, right: AdfMark): boolean {
return marksKey([left]) === marksKey([right])
}
export function identicalMarks(left: AdfNode, right: AdfNode): boolean {
return marksKey(nodeMarks(left)) === marksKey(nodeMarks(right))
}
// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do.
export function isAdfDocument(value: unknown): value is AdfDocument {
const fault = adfDocumentFault(value)
@@ -99,6 +118,13 @@ function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
return true
}
function marksKey(marks: readonly AdfMark[]): string {
return serializeCanonicalJson(
marks.map((mark) => (mark.attrs === undefined ? [mark.type] : [mark.type, mark.attrs])),
'compact',
)
}
function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
const pending: AdfNode[] = [...nodes]
while (pending.length > 0) {
+2 -2
View File
@@ -77,7 +77,7 @@ function mergesText(node: AdfNode): boolean {
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
}
export function sameMarks(previous: AdfNode, node: AdfNode): boolean {
function sameMarks(previous: AdfNode, node: AdfNode): boolean {
return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node))
}
@@ -86,5 +86,5 @@ function marksKey(marks: readonly AdfMark[]): string {
}
function markKey(mark: AdfMark): string {
return `${mark.type} ${serializeCanonicalJson(nodeAttrs(mark), 'compact')}`
return `${mark.type} ${serializeCanonicalJson(normalAttributes(nodeAttrs(mark)) ?? {}, 'compact')}`
}
+1 -1
View File
@@ -18,7 +18,7 @@ export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling)
const { depth, value: held } = next
if (Array.isArray(held)) schedule(pending, '[', held.map((item) => ({ label: '', value: item })), ']', indent, depth)
else if (held !== null && typeof held === 'object') schedule(pending, '{', objectMembers(held, indent), '}', indent, depth)
else text.push(JSON.stringify(held))
else text.push(Object.is(held, -0) ? '-0' : JSON.stringify(held))
}
return text.join('')
}
+1 -2
View File
@@ -8,7 +8,6 @@ import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
import { adfToPlainMarkdown, reduceToPlain } from '../markdown/emit/plain-reduction.ts'
import { directivePrefix } from '../markdown/directive-syntax.ts'
import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
const gateRuns = 1600
const renamedPrefix = '!adg:'
@@ -41,7 +40,7 @@ test('a generated document refuses to emit, or its markdown reads back to it', {
if (!emitted.ok) return
const read = markdownToAdf(emitted.value)
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
assert.deepEqual(toEditorNormal(read.value), document, `reading ${JSON.stringify(emitted.value)}`)
assert.deepEqual(read.value, document, `reading ${JSON.stringify(emitted.value)}`)
}),
propertyRuns(gateRuns),
)
+4 -5
View File
@@ -9,7 +9,6 @@ import { isAdfDocument } from '../adf/document.ts'
import { isJsonValue } from '../json-value.ts'
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus')
const errorsRoot = join(corpusRoot, 'errors')
@@ -97,7 +96,7 @@ for (const directory of roundTripDirectories) {
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
assert.deepEqual(toEditorNormal(result.value), expected)
assert.deepEqual(result.value, expected)
})
}
}
@@ -125,12 +124,12 @@ for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
assert.deepEqual(toEditorNormal(result.value), expected)
assert.deepEqual(result.value, expected)
const emitted = adfToMarkdown(result.value)
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
const again = markdownToAdf(emitted.value)
assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`)
assert.deepEqual(toEditorNormal(again.value), expected)
assert.deepEqual(again.value, expected)
})
}
@@ -146,7 +145,7 @@ for (const name of names(realPayloadsRoot, '.json')) {
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
const parsed = markdownToAdf(emitted.value)
assert.ok(parsed.ok, parsed.ok ? '' : `${parsed.error.code}: ${parsed.error.message}`)
assert.deepEqual(toEditorNormal(parsed.value), payload)
assert.deepEqual(parsed.value, payload)
})
}
+1 -2
View File
@@ -31,7 +31,6 @@ import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { nodeContent, nodeMarks } from '../adf/document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
import { textDirectiveName } from '../markdown/text-directive.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
type Choice = { arbitrary: Arbitrary<string>; hostile?: true; weight: number }
@@ -433,7 +432,7 @@ test('generated markdown refuses, or what it parses to refuses to emit, or its s
if (holdsDirectiveShape(parsed.value)) directiveShaped += 1
const read = markdownToAdf(emitted.value)
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
assert.deepEqual(toEditorNormal(read.value), toEditorNormal(parsed.value), `reading ${JSON.stringify(emitted.value)}`)
assert.deepEqual(read.value, parsed.value, `reading ${JSON.stringify(emitted.value)}`)
const respelled = adfToMarkdown(read.value)
assert.ok(respelled.ok, respelled.ok ? '' : `${respelled.error.code}: ${respelled.error.message} — spelling ${JSON.stringify(emitted.value)} again`)
assert.equal(respelled.value, emitted.value)
+50 -19
View File
@@ -11,7 +11,7 @@ import { blockNodes } from '../adf/block-nodes.ts'
import { directivePrefix } from '../markdown/directive-syntax.ts'
import { inlineNodes } from '../adf/inline-nodes.ts'
import { markAttributes } from '../adf/mark-attributes.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
import { mergeAdjacentText } from '../adf/editor-normal.ts'
type Positions = { block: AdfNode; inline: AdfNode }
@@ -36,7 +36,11 @@ export function textOf(minLength: number): Arbitrary<string> {
const text = textOf(1)
const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type))
const numberValue = fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 })
const numberValue = fc.oneof(
{ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 6 },
{ arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 2 },
{ arbitrary: fc.constant(-0), weight: 1 },
)
// V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash (https://issues.chromium.org/issues/521080746); Bun is unaffected.
const keyPiece = fc
@@ -73,19 +77,37 @@ function heldAttributes(held: Readonly<Record<string, JsonValue | undefined>>):
return attrs
}
// An empty attrs, content or marks key, and adjacent text a reader would merge, stay occasional: each takes a spelling outside CommonMark.
// The copy gives fast-check's null-prototype records the prototype a parsed node has.
function occasionallyEmpty<T extends AdfMark | AdfNode>(arbitrary: Arbitrary<T>): Arbitrary<T> {
return fc.tuple(arbitrary, fc.nat({ max: 9 })).map(([held, roll]) => (roll === 0 ? { ...held } : withoutEmptyKeys(held)))
}
function withoutEmptyKeys<T extends AdfMark | AdfNode>(held: T): T {
const kept = { ...held }
if (kept.attrs !== undefined && Object.keys(kept.attrs).length === 0) delete kept.attrs
if ('content' in kept && kept.content?.length === 0) delete kept.content
if ('marks' in kept && kept.marks?.length === 0) delete kept.marks
return kept
}
function occasionallyApart(arbitrary: Arbitrary<AdfNode[]>): Arbitrary<AdfNode[]> {
return fc.tuple(arbitrary, fc.nat({ max: 5 })).map(([nodes, roll]) => (roll === 0 ? nodes : mergeAdjacentText(nodes)))
}
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))]
return { content: rows.map((content): AdfNode => ({ content, type: 'tableRow' })), type: 'table' }
}
const mark: Arbitrary<AdfMark> = fc.oneof(
const mark: Arbitrary<AdfMark> = occasionallyEmpty(fc.oneof(
{ arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 },
{ arbitrary: attributes({ color: 'string' }).map((attrs) => ({ attrs, type: 'backgroundColor' })), weight: 2 },
{ arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 },
)
))
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
const textNode = fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' }))
const textNode = occasionallyEmpty(fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' })))
const backtickRunNode = fc
.record({ marks: fc.oneof(fc.constant<AdfMark[]>([]), fc.constant<AdfMark[]>([{ type: 'code' }]), marks), text: fc.string({ maxLength: 6, minLength: 1, unit: fc.constantFrom('`', '``', ' ', 'a') }) })
@@ -96,7 +118,7 @@ const autolinkTextNode = fc
.map(({ href, marks: held }): AdfNode => ({ marks: [...held.filter((outer) => outer.type !== 'link'), { attrs: { href }, type: 'link' }], text: href, type: 'text' }))
const inlineArbitraries = Object.entries(inlineNodes).map(([type, model]) =>
fc.record({ attrs: attributes(model.attributes), marks }).map((held): AdfNode => ({ ...held, type })),
occasionallyEmpty(fc.record({ attrs: attributes(model.attributes), marks }).map((held): AdfNode => ({ ...held, type }))),
)
function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): { arbitrary: Arbitrary<AdfNode>; weight: number }[] {
@@ -105,10 +127,10 @@ function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): {
const positions = fc.letrec<Positions>((tie) => {
const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 })
const inlineContent = fc.array(tie('inline'), { depthIdentifier, maxLength: 4 })
const inlineContent = occasionallyApart(fc.array(tie('inline'), { depthIdentifier, maxLength: 4 }))
const contentByModel = {
block: blockContent,
code: fc.array(text.map((held): AdfNode => ({ text: held, type: 'text' })), { maxLength: 2 }),
code: occasionallyApart(fc.array(text.map((held): AdfNode => ({ text: held, type: 'text' })), { maxLength: 2 })),
inline: inlineContent,
none: fc.constant<AdfNode[]>([]),
}
@@ -116,35 +138,40 @@ const positions = fc.letrec<Positions>((tie) => {
const blockArbitraries = Object.entries(blockNodes).map(([type, model]) => {
const argument = blockArgument(type)
const vocabulary: AttributeVocabulary = argument === undefined ? model.attributes : { ...model.attributes, [argument]: 'string' }
const node = fc.record({ attrs: attributes(vocabulary), content: contentByModel[model.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type }))
const node = occasionallyEmpty(fc.record({ attrs: attributes(vocabulary), content: contentByModel[model.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type })))
return { leaf: model.contentModel === 'code' || model.contentModel === 'none', node }
})
const unknownNode = fc
.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType })
.map((held): AdfNode => held)
const unknownNode = occasionallyEmpty(
fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType }),
)
const leafBlocks = blockArbitraries.filter((entry) => entry.leaf).map((entry) => entry.node)
const containerBlocks = blockArbitraries.filter((entry) => !entry.leaf).map((entry) => entry.node)
const misplacedWeight = 7
const paragraph = fc.oneof({ arbitrary: inlineContent, weight: 3 }, { arbitrary: fc.array(backtickRunNode, { maxLength: 4, minLength: 2 }), weight: 1 }).map((content): AdfNode => ({ content, type: 'paragraph' }))
const paragraph = occasionallyEmpty(
fc.oneof({ arbitrary: inlineContent, weight: 3 }, { arbitrary: occasionallyApart(fc.array(backtickRunNode, { maxLength: 4, minLength: 2 })), weight: 1 }).map((content): AdfNode => ({ content, type: 'paragraph' })),
)
const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type }))
const listItems = fc.array(
blockContent.map((content): AdfNode => ({ content, type: 'listItem' })),
occasionallyEmpty(blockContent.map((content): AdfNode => ({ content, type: 'listItem' }))),
{ depthIdentifier, maxLength: 3, minLength: 1 },
)
const flatCommonMarkShapes = [
fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' })),
occasionallyEmpty(fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' }))),
paragraph,
fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable),
]
const task = (type: string, content: Arbitrary<AdfNode[]>) =>
fc.record({ content, state: fc.constantFrom('DONE', 'TODO') }).map(({ content: held, state }): AdfNode => ({ attrs: { state }, content: held, type }))
occasionallyEmpty(fc.record({ content, state: fc.constantFrom('DONE', 'TODO') }).map(({ content: held, state }): AdfNode => ({ attrs: { state }, content: held, type })))
const taskItem = fc.oneof({ arbitrary: task('taskItem', inlineContent), weight: 3 }, { arbitrary: task('blockTaskItem', blockContent), weight: 1 })
const nestingCommonMarkShapes = [
blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })),
occasionallyEmpty(blockContent.map((content): AdfNode => ({ content, type: 'blockquote' }))),
fc.array(fc.oneof({ arbitrary: taskItem, weight: 3 }, { arbitrary: tie('block'), weight: 1 }), { depthIdentifier, maxLength: 3, minLength: 1 }).map((content): AdfNode => ({ content, type: 'taskList' })),
listItems.map((content): AdfNode => ({ content, type: 'bulletList' })),
fc
.record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) })
.record({
content: listItems,
order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 8 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 2 }, { arbitrary: fc.constant(-0), weight: 1 }),
})
.map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })),
]
const flatBlocks = [...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)]
@@ -167,7 +194,11 @@ const positions = fc.letrec<Positions>((tie) => {
}
})
export const adfDocument = fc.array(positions.block, { depthIdentifier, maxLength: 4, minLength: 1 }).map((content): AdfDocument => toEditorNormal({ content, type: 'doc', version: 1 }))
export const adfDocument = fc.oneof(
{ arbitrary: fc.array(positions.block, { depthIdentifier, maxLength: 4, minLength: 1 }).map((content): AdfDocument => ({ content, type: 'doc', version: 1 })), weight: 30 },
{ arbitrary: fc.constant<AdfDocument>({ content: [], type: 'doc', version: 1 }), weight: 1 },
{ arbitrary: fc.constant<AdfDocument>({ type: 'doc', version: 1 }), weight: 1 },
)
export function propertyRuns(gateRuns: number): { gate: boolean; numRuns: number; seed?: number } {
const deepRuns = env[deepRunsVariable]
+8 -6
View File
@@ -2,7 +2,7 @@ import type { AdfMark } from '../adf/document.ts'
import type { BlockType } from '../adf/block-nodes.ts'
import type { JsonValue } from '../json-value.ts'
import { blockNodeModel } from '../adf/block-nodes.ts'
import { isAdfMark, nodeAttrs } from '../adf/document.ts'
import { isAdfMark } from '../adf/document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
import { spellDirectiveOpener } from './directive-syntax.ts'
@@ -14,6 +14,11 @@ const argumentByType = new Map(
} satisfies Partial<Record<BlockType, string>>),
)
export const documentName = 'doc'
// spec/flavour.md, Directives: a document holding no content key, which the empty string cannot spell.
export const documentSpelling = spellDirectiveOpener(documentName, undefined, '{content=none}')
export const listBreakName = 'listBreak'
export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '')
@@ -25,17 +30,14 @@ export function blockArgument(type: string): string | undefined {
}
export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined {
if (name === listBreakName) return 'leaf'
if (name === listBreakName || name === documentName) return 'leaf'
const model = blockNodeModel(name)
if (model === undefined) return undefined
return model.contentModel === 'none' ? 'leaf' : 'container'
}
export function markValues(marks: readonly AdfMark[]): JsonValue {
return marks.map((mark) => {
const attrs = nodeAttrs(mark)
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
})
return marks.map((mark) => (mark.attrs === undefined ? { type: mark.type } : { attrs: mark.attrs, type: mark.type }))
}
export function readMarkValues(value: JsonValue): AdfMark[] | undefined {
+3 -5
View File
@@ -1,14 +1,12 @@
import type { JsonValue } from '../json-value.ts'
import { carryName } from './opaque-carry.ts'
import { holdsControlCharacter } from './commonmark/grammar.ts'
import { holdsEntityReference } from './commonmark/entity-references.ts'
import { carryFencePrefix } from './opaque-carry.ts'
import { infoStringCarries } from './commonmark/grammar.ts'
export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' }
// spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides.
export function languageSlot(language: JsonValue | undefined): LanguageSlot {
if (language === undefined) return { kind: 'none' }
if (typeof language !== 'string' || language === '' || language === carryName) return { kind: 'attribute' }
if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return { kind: 'attribute' }
if (typeof language !== 'string' || language.startsWith(carryFencePrefix) || !infoStringCarries(language)) return { kind: 'attribute' }
return { info: language, kind: 'fence' }
}
+6 -1
View File
@@ -1,4 +1,4 @@
import { readEntityReference, replacementCharacter } from './entity-references.ts'
import { holdsEntityReference, readEntityReference, replacementCharacter } from './entity-references.ts'
export type LinePosition = 'first' | 'later'
@@ -133,6 +133,11 @@ export function holdsControlCharacter(text: string): boolean {
return controlCharacter.test(text)
}
// What a backtick fence's info string reads back verbatim: escapes and entity references decode, and the edges trim.
export function infoStringCarries(text: string): boolean {
return text !== '' && !/[`\\]/.test(text) && !holdsControlCharacter(text) && text === text.trim() && !holdsEntityReference(text)
}
export function holdsNullCharacter(text: string): boolean {
return nullCharacter.test(text)
}
+1 -1
View File
@@ -139,7 +139,7 @@ export function spellStringAttribute(text: string): string {
export function spellAttributeValue(value: VocabularyValue): string {
if (value.kind === 'boolean') return `${value.value}`
if (value.kind === 'json') return spellJsonAttribute(value.value)
if (value.kind === 'number') return spellStringAttribute(JSON.stringify(value.value))
if (value.kind === 'number') return spellStringAttribute(serializeCanonicalJson(value.value, 'compact'))
return spellStringAttribute(value.value)
}
+41 -44
View File
@@ -6,14 +6,13 @@ import type { JsonValue } from '../../json-value.ts'
import type { Result } from '../../result.ts'
import { adfToMarkdown, markdownToAdf } from '../../index.ts'
import { largestNesting } from '../../nesting.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts'
function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
}
function paragraph(...content: AdfNode[]): AdfNode {
return { content, type: 'paragraph' }
return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' }
}
function code(result: Result<string>): string {
@@ -168,21 +167,24 @@ test('parts two adjacent lists of the same kind, the marker spelling being what
const nested: AdfNode = { content: [{ content: [list, list], type: 'listItem' }], type: 'bulletList' }
assert.equal(markdown(adfToMarkdown(document(nested))), '- - x\n\n !adf:listBreak\n\n - x\n')
const carried: AdfNode = { ...list, attrs: { unknown: 'x' } }
assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```carry\n'))
assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```adf:bulletList\n'))
assert.ok(markdown(adfToMarkdown(document(carried, list))).endsWith('```\n\n- x\n'))
assert.ok(markdown(adfToMarkdown(document(list, carried))).startsWith('- x\n\n```carry\n'))
assert.ok(markdown(adfToMarkdown(document(list, carried))).startsWith('- x\n\n```adf:bulletList\n'))
})
test('carries a node type no section spells', () => {
assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```carry\n{\n "type": "blockCard"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```carry\n{\n "type": "toString"\n}\n```\n')
test('carries a node type no section spells, the fence naming the type wherever an info string carries it', () => {
assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```adf:blockCard\n{}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```adf:toString\n{}\n```\n')
assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), '!adf:carry{json="{\\"type\\":\\"blockCard\\"}"}\n')
assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```carry\n{\n "text": "x",\n "type": "text"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```carry\n{\n "type": "hardBreak"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```adf:text\n{\n "text": "x"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```adf:hardBreak\n{}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'a&amp;b' }))), '```adf:\n{\n "type": "a&amp;b"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'a\\b' }))), '```adf:\n{\n "type": "a\\\\b"\n}\n```\n')
})
test('spells the code block whose language is the reserved info string', () => {
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '!adf:codeBlock {language=carry}\n```\n```\n!adf:/codeBlock\n')
test('spells the code block whose language opens with the reserved info string prefix', () => {
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf:x' }, type: 'codeBlock' }))), '!adf:codeBlock {language="adf:x"}\n```\n```\n!adf:/codeBlock\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '```carry\n```\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), '```adf\n```\n')
})
@@ -208,21 +210,17 @@ test('refuses a carried node nested deeper than the levels its position leaves',
assert.equal(code(adfToMarkdown(document(quoted))), 'unsupported-nesting-depth')
})
test('refuses a node whose content model the canonical form cannot emit', () => {
assert.equal(
markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))),
'unsupported-node-shape: a codeBlock holds plain text nodes only: this paragraph node is not one',
)
assert.equal(
markdown(adfToMarkdown(document({ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }], type: 'codeBlock' }))),
'unsupported-node-shape: a codeBlock holds plain text nodes only: this text node is not one',
)
test('carries a code block holding a node no fence holds', () => {
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), '```adf:codeBlock\n{\n "content": [\n {\n "type": "paragraph"\n }\n ]\n}\n```\n')
for (const child of [{ attrs: {}, text: 'x', type: 'text' }, { content: [], text: 'x', type: 'text' }, { marks: [], text: 'x', type: 'text' }, { content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }]) {
assert.ok(markdown(adfToMarkdown(document({ content: [child], type: 'codeBlock' }))).startsWith('```adf:codeBlock\n'), JSON.stringify(child))
}
})
test('spells a list its own content shape cannot hold as a directive', () => {
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), '!adf:bulletList\n!adf:paragraph\n!adf:/paragraph\n!adf:/bulletList\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), '!adf:bulletList\n!adf:/bulletList\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), '!adf:orderedList {order=2}\n!adf:/orderedList\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), '!adf:orderedList {content=empty order=2}\n!adf:/orderedList\n')
})
test('spells an ordered list no marker fits as a directive', () => {
@@ -334,7 +332,7 @@ test('refuses a node carrying one mark type twice', () => {
})
test('parts a nested list the tight spelling would swallow from the block above it', () => {
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' })
const item = (...content: AdfNode[]): AdfNode => (content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' })
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' })
const ordered: AdfNode = { attrs: { order: 2 }, content: [item(text('b'))], type: 'orderedList' }
@@ -345,7 +343,7 @@ test('parts a nested list the tight spelling would swallow from the block above
const list: AdfNode = { content: [item(text('b'))], type: 'bulletList' }
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [text('p')], type: 'panel' }
assert.equal(markdown(adfToMarkdown(outer(panel, list))), '- !adf:panel info\n p\n !adf:/panel\n\n - b\n')
assert.ok(markdown(adfToMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```carry\n'))
assert.ok(markdown(adfToMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```adf:bulletList\n'))
})
test('refuses marks and attributes nested deeper than the emitter carries', () => {
@@ -372,7 +370,7 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
const read = markdownToAdf(spelled.value)
assert.ok(read.ok, read.ok ? '' : read.error.message)
assert.deepEqual(toEditorNormal(read.value), document(node))
assert.deepEqual(read.value, document(node))
}
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em'))
@@ -442,7 +440,7 @@ test('escapes a hyphen underline a hard break would expose', () => {
})
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.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' })
assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')
const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' }
assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n')
@@ -469,10 +467,7 @@ test('refuses the characters CommonMark rewrites', () => {
)
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\u0000b', type: 'text' })))), 'unspellable-character')
assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))), 'unspellable-character')
assert.equal(
markdown(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))),
'unsupported-node-shape: a codeBlock holds plain text nodes only: this text node is not one',
)
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape: a text node holds text: this one has none')
})
test('refuses a text node the spelling would empty out', () => {
@@ -501,11 +496,11 @@ test('pads a code span whose edges CommonMark would strip', () => {
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: ' \t ', type: 'text' })))), '` \t `\n')
})
test('spells one code span over a run of code-marked nodes', () => {
test('spells a code span per code-marked node, parted by the text break', () => {
const code_ = { type: 'code' }
assert.equal(
markdown(adfToMarkdown(document(paragraph({ marks: [code_], text: 'a', type: 'text' }, { marks: [code_], text: 'b', type: 'text' })))),
'`ab`\n',
'`a`!adf:textBreak{}`b`\n',
)
})
@@ -545,7 +540,7 @@ test('emits an empty list item without trailing whitespace', () => {
test('spells a block directive as its node type, arg and attributes', () => {
const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' })
assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), '!adf:panel warning\nx\n!adf:/panel\n')
assert.equal(markdown(adfToMarkdown(panel({}))), '!adf:panel\nx\n!adf:/panel\n')
assert.equal(markdown(adfToMarkdown(panel({}))), '!adf:panel {attrs=empty}\nx\n!adf:/panel\n')
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), '!adf:caption\nx\n!adf:/caption\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '!adf:caption\n!adf:/caption\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '!adf:syncBlock {localId=a}\n')
@@ -553,20 +548,20 @@ test('spells a block directive as its node type, arg and attributes', () => {
test('carries a directive attribute no section spells', () => {
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```carry\n{\n "attrs": {\n "rounded": true\n },\n "type": "panel"\n}\n```\n')
assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```carry\n{\n "attrs": {\n "toString": "x"\n },\n "type": "panel"\n}\n```\n')
assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```carry\n{\n "attrs": {\n "localId": 4\n },\n "type": "panel"\n}\n```\n')
assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "rounded": true\n }\n}\n```\n')
assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "toString": "x"\n }\n}\n```\n')
assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "localId": 4\n }\n}\n```\n')
assert.equal(
carried({ attrs: { width: '50' }, type: 'layoutColumn' }),
'```carry\n{\n "attrs": {\n "width": "50"\n },\n "type": "layoutColumn"\n}\n```\n',
'```adf:layoutColumn\n{\n "attrs": {\n "width": "50"\n }\n}\n```\n',
)
assert.equal(
carried({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }),
'```carry\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n },\n "type": "table"\n}\n```\n',
'```adf:table\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n }\n}\n```\n',
)
assert.equal(
carried({ content: [{ attrs: { alt: 4 }, type: 'media' }], type: 'mediaGroup' }),
'!adf:mediaGroup\n```carry\n{\n "attrs": {\n "alt": 4\n },\n "type": "media"\n}\n```\n!adf:/mediaGroup\n',
'!adf:mediaGroup\n```adf:media\n{\n "attrs": {\n "alt": 4\n }\n}\n```\n!adf:/mediaGroup\n',
)
})
@@ -574,15 +569,16 @@ test('carries an arg slot value no bare token spells', () => {
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
assert.equal(
carried({ attrs: { panelType: 'extra info' }, type: 'panel' }),
'```carry\n{\n "attrs": {\n "panelType": "extra info"\n },\n "type": "panel"\n}\n```\n',
'```adf:panel\n{\n "attrs": {\n "panelType": "extra info"\n }\n}\n```\n',
)
assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```carry\n{\n "attrs": {\n "state": 2\n },\n "type": "taskItem"\n}\n```\n')
assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```adf:taskItem\n{\n "attrs": {\n "state": 2\n }\n}\n```\n')
})
test('carries a block node mark in the reserved attribute', () => {
const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' })
assert.equal(markdown(adfToMarkdown(section({ type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"attrs\\":{},\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
assert.equal(markdown(adfToMarkdown(section())), '!adf:layoutSection {marks=empty}\n!adf:/layoutSection\n')
})
test('refuses the content a directive body has no room for', () => {
@@ -604,7 +600,7 @@ test('separates blocks in a container body by a blank line only where a directiv
test('spells the image form for exactly the centered external media shape', () => {
const url = 'https://example.com/moon.png'
const single = (attrs: AdfAttributes, ...content: AdfNode[]): AdfDocument =>
document({ attrs: { layout: 'center' }, content: [{ attrs, content, type: 'media' }], type: 'mediaSingle' })
document({ attrs: { layout: 'center' }, content: [content.length === 0 ? { attrs, type: 'media' } : { attrs, content, type: 'media' }], type: 'mediaSingle' })
assert.equal(markdown(adfToMarkdown(single({ alt: 'The moon', type: 'external', url }))), `![The moon](${url})\n`)
assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `![](${url})\n`)
assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${url})\n`)
@@ -701,7 +697,8 @@ test('spells the directive marks around the longest run they cover', () => {
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
const underline: AdfMark = { type: 'underline' }
assert.equal(emitted(marked('x', underline)), '!adf:underline[x]\n')
assert.equal(emitted(marked('a', underline), marked('b', underline)), '!adf:underline[ab]\n')
assert.equal(emitted(marked('a', underline), marked('b', underline)), '!adf:underline[a!adf:textBreak{}b]\n')
assert.equal(emitted(marked('a', { attrs: {}, type: 'underline' }), marked('b', underline)), '!adf:underline[a]{attrs=empty}!adf:underline[b]\n')
assert.equal(emitted(marked('x', { attrs: { type: 'sub' }, type: 'subsup' })), '!adf:subsup[x]{type=sub}\n')
assert.equal(emitted(marked('x', { attrs: { color: '#ae2e24' }, type: 'textColor' })), '!adf:textColor[x]{color="#ae2e24"}\n')
assert.equal(emitted(marked('x', { attrs: { color: '#091e42', size: 2 }, type: 'border' })), '!adf:border[x]{color="#091e42" size=2}\n')
@@ -746,5 +743,5 @@ test('carries whitespace CommonMark strips in the reserved text directive', () =
test("joins a mark run's segments as a walk rather than as one call's arguments", () => {
const run = Array.from({ length: 200000 }, (): AdfNode => ({ marks: [{ type: 'strong' }], text: 'a', type: 'text' }))
assert.equal(markdown(adfToMarkdown(document({ content: run, type: 'paragraph' }))), `**${'a'.repeat(200000)}**\n`)
assert.equal(markdown(adfToMarkdown(document({ content: run, type: 'paragraph' }))), `**${Array.from({ length: 200000 }, () => 'a').join('!adf:textBreak{}')}**\n`)
})
+27 -27
View File
@@ -1,16 +1,16 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import type { Flavour } from '../plain-conventions.ts'
import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain-conventions.ts'
import { blockDirectiveForm, listBreakSpelling } from '../block-directive.ts'
import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts'
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
import { carriedBlock } from '../opaque-carry.ts'
import { emitInlineLine } from './inline-line.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { fencedCodeBlock } from '../commonmark/backtick-runs.ts'
import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts'
import { languageSlot } from '../code-language.ts'
import { languageSlot, type LanguageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { spellBlockDirectiveOpener } from './block-directive-spelling.ts'
import { spellDirectiveCloser, spellDirectiveOpener } from '../directive-syntax.ts'
@@ -40,7 +40,8 @@ export function writeMarkdown(document: AdfDocument, flavour: Flavour): Result<s
const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const walk = walkBlocks(nodeContent(document), [], 0, { flavour, memo: undefined })
if (document.content === undefined) return success(`${documentSpelling}\n`)
const walk = walkBlocks(document.content, [], 0, { flavour, memo: undefined })
if (!walk.ok) return walk
const text = joinBlocks(walk.value.blocks, 'document')
return success(text === '' ? '' : `${text}\n`)
@@ -254,40 +255,39 @@ function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBloc
if (!carriesOnly(node, ['language'])) return undefined
const slot = languageSlot(nodeAttrs(node)['language'])
if (slot.kind === 'attribute') return undefined
const text = codeBlockText(node, path)
if (!text.ok) return text
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
const texts = fencedTexts(node, path)
if (!texts.ok) return texts
const [only, ...others] = texts.value ?? []
if (only === undefined || others.length > 0) return undefined
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', only)))
}
// spec/flavour.md, The CommonMark blocks: one fence per text node, the language on each; with no fence to carry it, the attribute does.
function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const slot = languageSlot(nodeAttrs(node)['language'])
const texts = fencedTexts(node, path)
if (!texts.ok) return texts
if (texts.value === undefined) return commonMarkLine(carriedBlock(node, path, depth))
const slot: LanguageSlot = texts.value.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language'])
const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language'])
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (!opener.ok) return opener
const text = codeBlockText(node, path)
if (!text.ok) return text
return success(directivePair(node, opener.value, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
const info = slot.kind === 'fence' ? slot.info : ''
return success(directivePair(node, opener.value, texts.value.map((text) => fencedCodeBlock(info, text)).join('\n')))
}
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
let text = ''
for (const [index, child] of nodeContent(node).entries()) {
// The text each fence holds, or `undefined` where a child is no plain text node, which the carry holds instead.
function fencedTexts(node: AdfNode, path: ConvertErrorPath): Result<string[] | undefined> {
if (node.content === undefined) return success([''])
if (node.content.some((child) => child.type !== 'text' || child.attrs !== undefined || child.content !== undefined || child.marks !== undefined)) return success(undefined)
const texts: string[] = []
for (const [index, child] of node.content.entries()) {
const childPath = [...path, 'content', index]
if (
child.type !== 'text' ||
typeof child.text !== 'string' ||
child.text === '' ||
nodeContent(child).length > 0 ||
nodeMarks(child).length > 0 ||
Object.keys(nodeAttrs(child)).length > 0
) {
return failure('unsupported-node-shape', `a codeBlock holds plain text nodes only: this ${child.type} node is not one`, childPath)
}
if (typeof child.text !== 'string' || child.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', childPath)
if (/\r/.test(child.text)) return failure('unspellable-character', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', childPath)
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
text += child.text
texts.push(child.text)
}
return success(text)
return success(texts)
}
function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
@@ -340,7 +340,7 @@ function directiveItems(items: readonly WalkedItem[]): PlacedBlock[] {
function listStart(node: AdfNode, items: number): number | undefined {
if (node.type !== 'orderedList') return 0
const start = nodeAttrs(node)['order']
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || Object.is(start, -0) || start > largestListMarker) return undefined
return start + items - 1 > largestListMarker ? undefined : start
}
@@ -5,6 +5,7 @@ import { blockArgument, markValues, marksAttribute } from '../block-directive.ts
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
import { overNested } from '../../json-value.ts'
import { spellEmptyKeys } from '../empty-keys.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, spelledByBody: readonly string[] = []): Result<string> | undefined {
@@ -14,7 +15,7 @@ export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel,
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, spelled)
if (pairs === undefined) return undefined
const spelledPairs = spellVocabulary(pairs)
const spelledPairs = [...spellVocabulary(pairs), ...spellEmptyKeys(node)]
const marks = nodeMarks(node)
if (marks.length > 0) {
const values = markValues(marks)
@@ -2,9 +2,10 @@ import type { AdfNode } from '../../adf/document.ts'
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
import { nodeAttrs } from '../../adf/document.ts'
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
import { spellEmptyKeys } from '../empty-keys.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellInlineNodeAttributes(node: AdfNode, model: InlineNodeModel): string | undefined {
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, model.textAttribute === undefined ? [] : [model.textAttribute])
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
return pairs === undefined ? undefined : spellAttributes([...spellVocabulary(pairs), ...spellEmptyKeys(node)])
}
+22 -14
View File
@@ -6,14 +6,14 @@ import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment,
import { carriedInline } from '../opaque-carry.ts'
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { emptyKeys, identicalMark, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { highlightDelimiter } from '../plain-conventions.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { sameMark } from '../../adf/editor-normal.ts'
import { readsAsOne, textBreakSpelling } from '../text-break.ts'
import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
import { spellTextDirective } from '../text-directive.ts'
@@ -186,6 +186,7 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c
const runs = inlineRuns(nodes, depth, firstIndex, context.carried)
const segments: InlineSegment[] = []
for (const [offset, run] of runs.entries()) {
if (partsText(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling))
const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 }
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
if (!emitted.ok) return emitted
@@ -206,12 +207,18 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
continue
}
const previous = runs[runs.length - 1]
if (previous?.kind === 'marked' && sameMark(previous.mark, mark)) previous.nodes.push(node)
if (previous?.kind === 'marked' && identicalMark(previous.mark, mark)) previous.nodes.push(node)
else runs.push({ index, kind: 'marked', mark, nodes: [node] })
}
return runs
}
function partsText(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet<number>): boolean {
if (previous?.kind !== 'plain' || run.kind !== 'plain') return false
if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false
return readsAsOne(previous.node, run.node)
}
function nodePath(context: InlineContext, index: number): ConvertErrorPath {
return [...context.path, 'content', index]
}
@@ -261,7 +268,7 @@ function emitInlineDirective(node: AdfNode, model: InlineNodeModel, index: numbe
}
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
if (Object.keys(nodeAttrs(node)).length > 0) return success({ carry: { first: index, last: index } })
if (node.attrs !== undefined || emptyKeys(node).length > 0) return success({ carry: { first: index, last: index } })
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
@@ -277,7 +284,7 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number,
if (mark.type === 'backgroundColor' && context.flavour === 'plain') return emitHighlight(nodes, depth, range, context)
const spelling = markSpelling(mark.type)
if (spelling === undefined) return success({ carry: range })
const attributes = spellMarkAttributes(mark, spelling.attributes)
const attributes = spellMarkAttributes(mark, spelling)
if (attributes === undefined) return success({ carry: range })
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path)
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
@@ -312,19 +319,20 @@ function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRang
})
}
// Each node its own span: CommonMark reads two adjacent text nodes in one span back as one.
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
let text = ''
const spans: string[] = []
for (const node of nodes) {
if (node.type !== 'text' || nodeMarks(node).length !== depth + 1) return success({ carry: range })
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
if (node.type !== 'text' || nodeMarks(node).length !== depth + 1 || node.attrs !== undefined || emptyKeys(node).length > 0) return success({ carry: range })
const { text } = node
if (typeof text !== 'string' || text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
text += node.text
if (/[\n\r]/.test(text)) return success({ carry: range })
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
const fence = '`'.repeat(longestBacktickRun(text) + 1)
spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`)
}
if (/[\n\r]/.test(text)) return success({ carry: range })
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
const fence = '`'.repeat(longestBacktickRun(text) + 1)
const padded = needsPadding(text) ? ` ${text} ` : text
return success({ segments: [syntax(`${fence}${padded}${fence}`)] })
return success({ segments: [syntax(spans.join(textBreakSpelling))] })
}
function needsPadding(text: string): boolean {
+1 -1
View File
@@ -177,7 +177,7 @@ test('keeps the CommonMark blocks in their spelling and drops their attributes a
assert.equal(plain(node('paragraph', localId, text('x')), node('heading', { level: 2, localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de' }, text('h'))), 'x\n\n## h\n')
assert.equal(plain({ attrs: localId, content: [said('q')], marks: [{ type: 'breakout' }], type: 'blockquote' }), '> q\n')
assert.equal(plain(node('codeBlock', { language: 'ts', wrap: true }, text('a\r\nb\u0000'))), '```ts\na\nb\n```\n')
assert.equal(plain(node('codeBlock', { language: 'carry' }, text('a'), { type: 'hardBreak' }, text('b', strong))), '```\na\nb\n```\n')
assert.equal(plain(node('codeBlock', { language: 'adf:x' }, text('a'), { type: 'hardBreak' }, text('b', strong))), '```\na\nb\n```\n')
assert.equal(plain(node('codeBlock', {})), '```\n```\n')
assert.equal(plain(node('rule', { color: '#000' })), '---\n')
assert.equal(plain(node('orderedList', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', order: 3 }, item(said('c')))), '3. c\n')
+6 -3
View File
@@ -8,6 +8,7 @@ import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { taskMarker } from '../plain-conventions.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts'
// depth: the level the node reduced stands at, counted as the emitter counts it.
type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
@@ -50,8 +51,9 @@ export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const blocks = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
return blocks.ok ? success({ content: blocks.value, type: 'doc', version: 1 }) : blocks
// docs/decisions.md §Equality is deep: the plain flavour reads and writes editor-normal ADF.
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
return blocks.ok ? success({ content: nodeContent(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 })).slice(), type: 'doc', version: 1 }) : blocks
}
function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
@@ -262,7 +264,8 @@ function listItem(blocks: Result<AdfNode[]>): Result<AdfNode[]> {
// A list item's first line reads as no rule and holds no line of spaces alone: the rule and the spaces give way.
function itemOf(blocks: readonly AdfNode[]): AdfNode {
const rules = blocks.findIndex((block) => block.type !== 'rule')
return { content: blankedCode(blocks.slice(rules === -1 ? blocks.length : rules)), type: 'listItem' }
const content = blankedCode(blocks.slice(rules === -1 ? blocks.length : rules))
return content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' }
}
function blankedCode(blocks: readonly AdfNode[]): AdfNode[] {
+30
View File
@@ -0,0 +1,30 @@
import type { DirectiveAttributes, DirectiveValue, Read } from './directive-syntax.ts'
import type { EmptyKey } from '../adf/document.ts'
import { emptyKeys } from '../adf/document.ts'
import { unsupportedNodeShape } from './directive-syntax.ts'
type EmptyKeysRead = { empty: ReadonlySet<EmptyKey>; rest: Map<string, DirectiveValue> }
const emptyValue = 'empty'
// spec/flavour.md, Attributes: no attribute value spells an empty object or array.
export function spellEmptyKeys(held: Parameters<typeof emptyKeys>[0]): [string, string][] {
return emptyKeys(held).map((key) => [key, emptyValue])
}
export function spellsEmpty(value: DirectiveValue | undefined): boolean {
return value?.spelling === emptyValue
}
export function readEmptyKeys(attributes: DirectiveAttributes, keys: readonly EmptyKey[]): Read<EmptyKeysRead> {
const rest = new Map(attributes)
const empty = new Set<EmptyKey>()
for (const key of keys) {
const spelled = rest.get(key)
if (spelled === undefined) continue
if (!spellsEmpty(spelled)) return { fault: unsupportedNodeShape(`the reserved key ${key} reads ${key}=${emptyValue} alone: this one spells ${key}=${spelled.spelling}`) }
empty.add(key)
rest.delete(key)
}
return { value: { empty, rest } }
}
+7 -3
View File
@@ -7,6 +7,7 @@ import { holdsEntityReference } from './commonmark/entity-references.ts'
import { isAutolink } from './commonmark/grammar.ts'
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
import { nodeAttrs, nodeMarks } from '../adf/document.ts'
import { spellEmptyKeys } from './empty-keys.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string }
@@ -55,7 +56,10 @@ function isBareLink(nodes: readonly AdfNode[], href: string, marksInside: number
return nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === marksInside
}
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined {
const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, [])
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
// `undefined` where the spelling holds no such attrs: only a directive spells attrs=empty.
export function spellMarkAttributes(mark: AdfMark, spelling: MarkSpelling): string | undefined {
const pairs = vocabularyPairs(nodeAttrs(mark), spelling.attributes, [])
const empty = spellEmptyKeys(mark)
if (pairs === undefined || (empty.length > 0 && spelling.kind !== 'directive')) return undefined
return spellAttributes([...spellVocabulary(pairs), ...empty])
}
+31 -12
View File
@@ -1,32 +1,41 @@
import type { AdfNode } from '../adf/document.ts'
import type { DirectiveSpan, Read } from './directive-syntax.ts'
import type { JsonSpelling } from '../canonical-json.ts'
import type { JsonValue } from '../json-value.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
import { fencedCodeBlock } from './commonmark/backtick-runs.ts'
import { infoStringCarries } from './commonmark/grammar.ts'
import { isAdfNode } from '../adf/document.ts'
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
import { largestNesting } from '../nesting.ts'
import { malformedDirective, readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
export const carryFencePrefix = 'adf:'
export const carryName = 'carry'
const jsonAttribute = 'json'
// spec/flavour.md, The opaque carry: the info string names the type wherever it carries the type back.
export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<{ headroom: number; text: string }> {
const json = carriedJson(node, 'two-space', path, largestNesting - depth)
const { type, ...untyped } = node
const named = infoStringCarries(type)
const json = carriedJson(node, named ? untyped : node, 'two-space', path, largestNesting - depth)
if (!json.ok) return json
return success({ headroom: json.value.headroom, text: fencedCodeBlock(carryName, json.value.json) })
return success({ headroom: json.value.headroom, text: fencedCodeBlock(`${carryFencePrefix}${named ? type : ''}`, json.value.json) })
}
export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> {
const json = carriedJson(node, 'compact', path, largestNesting)
const json = carriedJson(node, node, 'compact', path, largestNesting)
if (!json.ok) return json
return success(spellInlineLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value.json)]])))
}
export function readCarriedBlock(body: string, depth: number): Read<AdfNode> {
return readCarriedJson(body, 'two-space', largestNesting - depth)
export function readCarriedBlock(type: string, body: string, depth: number): Read<AdfNode> {
const read = readCarriedJson(body, 'two-space', largestNesting - depth, type)
if (read.fault !== undefined || type !== '' || !infoStringCarries(read.value.type)) return read
return { fault: unsupportedNodeShape(`the carry fence names a type its info string carries: spell it ${carryFencePrefix}${read.value.type}`) }
}
export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefined {
@@ -36,28 +45,38 @@ export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefine
return readCarriedJson(spelled.value, 'compact', largestNesting)
}
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<{ headroom: number; json: string }> {
function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<{ headroom: number; json: string }> {
const headroom = levels - nestingDepth(node)
if (!isJsonValue(node) || headroom < 0) {
if (!isJsonValue(spelled) || headroom < 0) {
return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path)
}
return success({ headroom, json: serializeCanonicalJson(node, spelling) })
return success({ headroom, json: serializeCanonicalJson(spelled, spelling) })
}
function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number): Read<AdfNode> {
// `type` is the one the fence's info string names, `undefined` for the inline carry.
function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number, type?: string): Read<AdfNode> {
const parsed = parseJsonText(raw)
if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') }
const { value } = parsed
if (!isJsonValue(value)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') }
if (overNested(value, levels)) {
const typed = type === undefined || type === '' ? { value } : typedValue(value, type)
if (typed.fault !== undefined) return typed
const held = typed.value
if (overNested(held, levels)) {
return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${levels} levels its position leaves` } }
}
if (serializeCanonicalJson(value, spelling) !== raw) {
const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted'
return { fault: unsupportedNodeShape(`the opaque carry spells its node's JSON canonically: ${shape}`) }
}
if (!isAdfNode(value)) return { fault: unsupportedNodeShape("the opaque carry holds one ADF node's JSON: this JSON is no ADF node") }
return { value }
if (!isAdfNode(held)) return { fault: unsupportedNodeShape("the opaque carry holds one ADF node's JSON: this JSON is no ADF node") }
return { value: held }
}
function typedValue(value: JsonValue, type: string): Read<JsonValue> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { value }
if ('type' in value) return { fault: unsupportedNodeShape(`the ${carryFencePrefix}${type} fence names its node's type: this JSON holds a type as well`) }
return { value: { ...value, type } }
}
function parseJsonText(raw: string): { value: unknown } | undefined {
+6 -2
View File
@@ -3,8 +3,9 @@ import type { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes } from '../directive-syntax.ts'
import type { MarkSpelling } from '../mark-spellings.ts'
import { directivePrefix } from '../directive-syntax.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { markSpelling } from '../mark-spellings.ts'
import { readEmptyKeys } from '../empty-keys.ts'
import { readVocabulary } from './directive-attributes.ts'
export function readDirectiveMark(name: string, attributes: DirectiveAttributes, path: ConvertErrorPath): Result<AdfMark> | undefined {
@@ -12,8 +13,11 @@ export function readDirectiveMark(name: string, attributes: DirectiveAttributes,
if (spelling === undefined) return undefined
const markdown = markdownForm(spelling)
if (markdown !== undefined) return failure('unsupported-node-shape', `${name} is spelled ${markdown}, never as a directive`, path)
const attrs = readVocabulary(name, attributes, spelling.attributes, undefined, path)
const empty = readEmptyKeys(attributes, ['attrs'])
if (empty.fault !== undefined) return faulted(empty.fault, path)
const attrs = readVocabulary(name, empty.value.rest, spelling.attributes, undefined, path)
if (!attrs.ok) return attrs
if (empty.value.empty.has('attrs')) return Object.keys(attrs.value).length === 0 ? success({ attrs: {}, type: name }) : failure('unsupported-node-shape', `${name} spells attrs=empty beside an attribute it holds`, path)
return success(Object.keys(attrs.value).length === 0 ? { type: name } : { attrs: attrs.value, type: name })
}
+27 -14
View File
@@ -1,4 +1,4 @@
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
import type { AdfAttributes, AdfMark, AdfNode, EmptyKey } from '../../adf/document.ts'
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import type { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
@@ -7,15 +7,18 @@ import { attributeNestingMessage, nodeAttrs, nodeContent, nodeMarks } from '../.
import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { carryName } from '../opaque-carry.ts'
import { carryFencePrefix, carryName } from '../opaque-carry.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineMarkSpellingFault } from './directive-marks.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { readEmptyKeys, spellsEmpty } from '../empty-keys.ts'
import { readVocabulary } from './directive-attributes.ts'
import { slotLineEndingFault } from '../directive-syntax.ts'
import { textBreakName } from '../text-break.ts'
import { textDirectiveName } from '../text-directive.ts'
export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; node: AdfNode }
// `emptyContent` is whether the directive spells content=empty, which holds no body.
export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; emptyContent: boolean; node: AdfNode }
export function readBlockDirectiveNode(
name: string,
@@ -24,12 +27,15 @@ export function readBlockDirectiveNode(
path: ConvertErrorPath,
): Result<BlockDirectiveNode> {
if (name === carryName) {
return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryName} fence`, path)
return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryFencePrefix} fence`, path)
}
const model = blockNodeModel(name)
if (model === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path)
const argumentKey = blockArgument(name)
const rest = new Map(attributes)
const spelled = attributes.get(marksAttribute)
const empty = readEmptyKeys(attributes, spellsEmpty(spelled) ? ['attrs', 'content', 'marks'] : ['attrs', 'content'])
if (empty.fault !== undefined) return faulted(empty.fault, path)
const { rest } = empty.value
rest.delete(marksAttribute)
const elsewhere: Elsewhere | undefined = argumentKey === undefined ? undefined : { key: argumentKey, slot: 'argument' }
const attrs = readVocabulary(name, rest, model.attributes, elsewhere, path)
@@ -38,10 +44,11 @@ export function readBlockDirectiveNode(
if (argumentKey === undefined) return failure('unsupported-node-shape', `${name} takes no argument: this one spells one`, path)
attrs.value[argumentKey] = argument
}
const spelled = attributes.get(marksAttribute)
const marks: Result<AdfMark[] | undefined> = spelled === undefined ? success(undefined) : readMarks(name, spelled, path)
const marks: Result<AdfMark[] | undefined> = spelled === undefined || spellsEmpty(spelled) ? success(undefined) : readMarks(name, spelled, path)
if (!marks.ok) return marks
return success({ contentModel: model.contentModel, node: namedNode(name, attrs.value, marks.value) })
const node = namedNode(name, attrs.value, marks.value, empty.value.empty, path)
if (!node.ok) return node
return success({ contentModel: model.contentModel, emptyContent: empty.value.empty.has('content'), node: node.value })
}
export function readInlineDirectiveNode(
@@ -55,7 +62,9 @@ export function readInlineDirectiveNode(
const slot = model.textAttribute
if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path)
const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' }
const attrs = readVocabulary(name, attributes, model.attributes, elsewhere, path)
const empty = readEmptyKeys(attributes, ['attrs', 'content', 'marks'])
if (empty.fault !== undefined) return faulted(empty.fault, path)
const attrs = readVocabulary(name, empty.value.rest, model.attributes, elsewhere, path)
if (!attrs.ok) return attrs
if (slot !== undefined && content !== undefined) {
const text = slotText(content)
@@ -66,14 +75,14 @@ export function readInlineDirectiveNode(
if (spans !== undefined) return faulted(spans, path)
attrs.value[slot] = text
}
return success(namedNode(name, attrs.value, undefined))
return namedNode(name, attrs.value, undefined, empty.value.empty, path)
}
// A name the other position spells names that spelling, never the code a later MINOR may fill (docs/decisions.md §Which code a cause takes).
function inlineSpellingFault(name: string): ConvertFault | undefined {
const mark = inlineMarkSpellingFault(name)
if (mark !== undefined) return mark
if (inlineNodeModel(name) === undefined && name !== textDirectiveName) return undefined
if (inlineNodeModel(name) === undefined && name !== textDirectiveName && name !== textBreakName) return undefined
return { code: 'unsupported-node-shape', message: `${name} takes the inline form, ${directivePrefix}${name}{…}, never the block form` }
}
@@ -100,7 +109,11 @@ function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath
return success(marks)
}
function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined): AdfNode {
const named = Object.keys(attrs).length === 0 ? { type } : { attrs, type }
return marks === undefined ? named : { ...named, marks: [...marks] }
function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined, empty: ReadonlySet<EmptyKey>, path: ConvertErrorPath): Result<AdfNode> {
const held = Object.keys(attrs).length > 0
if (held && empty.has('attrs')) return failure('unsupported-node-shape', `${type} spells attrs=empty beside an attribute it holds`, path)
const node: AdfNode = held || empty.has('attrs') ? { attrs, type } : { type }
if (empty.has('content')) node.content = []
if (marks !== undefined || empty.has('marks')) node.marks = [...(marks ?? [])]
return success(node)
}
+67 -19
View File
@@ -10,16 +10,16 @@ import { commonMarkLink, linkHref } from '../mark-spellings.ts'
import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { highlightDelimiter, highlightFlanking } from '../plain-conventions.ts'
import { identicalMarks, nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { mergeAdjacentText, sameMarks } from '../../adf/editor-normal.ts'
import { noSpans, readInlineDirective } from '../directive-syntax.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts'
import { openingLinkTakesDirective } from '../emit/inline-line.ts'
import { readCarriedInline } from '../opaque-carry.ts'
import { readDirectiveMark } from './directive-marks.ts'
import { readInlineDirectiveNode } from './directive-nodes.ts'
import { readTextDirective } from '../text-directive.ts'
import { readsAsOne, textBreakName, textBreakSpelling } from '../text-break.ts'
export type InlineContent = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: AdfNode[] }
@@ -31,8 +31,10 @@ type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; lin
type Pairing = EmphasisPairing<Run>
// A carry piece holds a node whose marks are its own: an opaque carry, or an inline node spelling marks=empty.
type Piece =
| Bracket
| { kind: 'break' }
| { kind: 'carry'; node: AdfNode }
| { alt: string; kind: 'image'; node: AdfNode }
| { kind: 'nodes'; nodes: AdfNode[] }
@@ -43,6 +45,8 @@ type Run = { canClose: boolean; canOpen: boolean; character: string; index: numb
// `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`.
type Scan = {
// Nodes a carry piece restored: they never merge, and only identity tells a carried textBreak from the leaf.
carried: Set<AdfNode>
container: LineContainer | undefined
// Pieces below this have been walked for openers to deactivate: an image close folds the link-marked piece into alt text, leaving this the only record that the brackets around it are doomed.
deactivatedBefore: number
@@ -58,7 +62,7 @@ type Scan = {
type SlotContent = { carry: boolean; nodes: AdfNode[] }
const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
const carriedInMark = 'no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own'
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
const hreflessLink = 'the link mark spells its href: this one spells none'
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
@@ -66,11 +70,11 @@ const linkInLink = 'no link wraps a link: the [content] this one marks already h
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
return parseInline(source, definitions, path, container, noSpans, flavour === 'plain')
return parseInline(source, { carried: new Set(), container, definitions, highlights: flavour === 'plain', path, spans: noSpans })
}
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans, highlights: boolean): Result<InlineContent> {
const scan: Scan = { container, deactivatedBefore: 0, definitions, highlights, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
function parseInline(source: string, context: Pick<Scan, 'carried' | 'container' | 'definitions' | 'highlights' | 'path' | 'spans'>): Result<InlineContent> {
const scan: Scan = { ...context, deactivatedBefore: 0, openingSpellableLink: false, pending: '', pieces: [], source }
let index = 0
while (index < source.length) {
switch (source.charAt(index)) {
@@ -103,7 +107,7 @@ function parseInline(source: string, definitions: LinkDefinitions, path: Convert
index = readCharacter(scan, index)
}
}
flush(scan, container !== undefined)
flush(scan, scan.container !== undefined)
return assemble(scan)
}
@@ -205,8 +209,9 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
const carried = readCarriedInline(span)
if (carried !== undefined) {
if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
return success({ kind: 'carry', node: carried.value })
return success(carryPiece(scan, carried.value))
}
if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success({ kind: 'break' }) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.path)
const text = readTextDirective(span)
if (text?.fault !== undefined) return faulted(text.fault, scan.path)
if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] })
@@ -216,7 +221,12 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark
const node = readInlineDirectiveNode(span.name, span.attributes, slot.value?.nodes, scan.path)
if (!node.ok) return node
return success({ kind: 'nodes', nodes: [node.value] })
return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : carryPiece(scan, node.value))
}
function carryPiece(scan: Scan, node: AdfNode): Piece {
scan.carried.add(node)
return { kind: 'carry', node }
}
function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result<Piece> {
@@ -242,7 +252,7 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
if (span.content === undefined) return success(undefined)
const parsed = parseInline(span.content, scan.definitions, scan.path, undefined, span.spans, false)
const parsed = parseInline(span.content, { ...scan, container: undefined, highlights: false, spans: span.spans })
if (!parsed.ok) return parsed
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
return success(parsed.value)
@@ -262,7 +272,8 @@ function assemble(scan: Scan): Result<InlineContent> {
const only = scan.pieces[0]
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
const nodes = resolveNodes(scan.pieces, scan.path, true)
const resolved = resolveNodes(scan.pieces, scan, true)
const nodes = resolved.ok && scan.container !== undefined ? partText(resolved.value, scan) : resolved
if (!nodes.ok) return nodes
if (scan.openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes.value, scan.path)
@@ -272,6 +283,27 @@ function assemble(scan: Scan): Result<InlineContent> {
return success({ carry: holdsCarry(scan.pieces), nodes: nodes.value })
}
// spec/flavour.md, Inline nodes: the leaf builds no node, so only the pair it parts spells it.
function partText(nodes: readonly AdfNode[], scan: Scan): Result<AdfNode[]> {
const parted: AdfNode[] = []
for (const [index, node] of nodes.entries()) {
if (!isTextBreak(node, scan.carried)) {
parted.push(node)
continue
}
const previous = nodes[index - 1]
const next = nodes[index + 1]
if (previous === undefined || next === undefined || scan.carried.has(previous) || scan.carried.has(next) || !readsAsOne(previous, next)) {
return failure('unsupported-node-shape', `${textBreakName} parts two text nodes CommonMark reads back as one: this one parts something else`, scan.path)
}
}
return success(parted)
}
function isTextBreak(node: AdfNode, carried: ReadonlySet<AdfNode>): boolean {
return node.type === textBreakName && !carried.has(node)
}
function holdsCarry(pieces: readonly Piece[]): boolean {
return pieces.some((piece) => piece.kind === 'carry')
}
@@ -383,7 +415,7 @@ function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition:
}
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path)
const resolved = resolveNodes(inner, scan.path, true)
const resolved = resolveNodes(inner, scan, true)
if (!resolved.ok) return resolved
const nodes = resolved.value
// An empty link text gives the mark no node to ride, so the brackets stay text.
@@ -411,7 +443,7 @@ function truncatePieces(scan: Scan, to: number): void {
function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<null> {
if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path)
const resolved = imageAlt(inner, scan.path)
const resolved = imageAlt(inner, scan)
if (!resolved.ok) return resolved
const alt = resolved.value
const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination }
@@ -420,9 +452,10 @@ function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition:
return success(null)
}
function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<string> {
const nodes = resolveNodes(inner, path, false)
function imageAlt(inner: readonly Piece[], scan: Scan): Result<string> {
const nodes = resolveNodes(inner, scan, false)
if (!nodes.ok) return nodes
if (nodes.value.some((node) => isTextBreak(node, scan.carried))) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: an image description holds plain text`, scan.path)
return success(nodes.value.map(altText).join(''))
}
@@ -435,20 +468,35 @@ function altText(node: AdfNode): string {
}
// An image's alt text is plain, so `highlights` is off there and every `==` stays text.
function resolveNodes(pieces: readonly Piece[], path: ConvertErrorPath, highlights: boolean): Result<AdfNode[]> {
function resolveNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean): Result<AdfNode[]> {
const nodes = pieces.map(pieceNodes)
const runs = delimiterRuns(pieces)
const pairings = matchEmphasis(runs)
writeUnpaired(nodes, runs, pairings)
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, path)
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, scan.path)
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
return success(mergeAdjacentText(nodes.flat()))
return success(mergeText(nodes.flat(), scan.carried))
}
function mergeText(nodes: readonly AdfNode[], carried: ReadonlySet<AdfNode>): AdfNode[] {
const merged: AdfNode[] = []
for (const node of nodes) {
const previous = merged[merged.length - 1]
if (previous !== undefined && !carried.has(previous) && !carried.has(node) && readsAsOne(previous, node)) {
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
continue
}
merged.push(node)
}
return merged
}
// Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first.
// A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it.
function pieceNodes(piece: Piece): AdfNode[] {
switch (piece.kind) {
case 'break':
return [{ type: textBreakName }]
case 'carry':
return [piece.node]
case 'highlight':
@@ -531,7 +579,7 @@ function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][])
let candidate = found[closer]
while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) candidate = found[(closer += 1)]
if (candidate === undefined) break
if (candidate.line !== opener.line || !sameMarks(opener.holder, candidate.holder)) continue
if (candidate.line !== opener.line || !identicalMarks(opener.holder, candidate.holder)) continue
paired.push({ closer: candidate.index, opener: opener.index })
resume = candidate.position + highlightDelimiter.length
}
+40 -17
View File
@@ -84,9 +84,21 @@ function table(...rows: AdfNode[]): AdfNode {
return { content: rows, type: 'table' }
}
test('builds an empty document from input holding no block', () => {
assert.deepEqual(markdownToAdf(''), { ok: true, value: { type: 'doc', version: 1 } })
assert.deepEqual(content(markdownToAdf('\n \n\t\n')), [])
test('builds an empty document from input holding no block, and one holding no content key from the doc directive alone', () => {
assert.deepEqual(markdownToAdf(''), { ok: true, value: { content: [], type: 'doc', version: 1 } })
assert.deepEqual(markdownToAdf('\n \n\t\n'), { ok: true, value: { content: [], type: 'doc', version: 1 } })
assert.deepEqual(markdownToAdf('!adf:doc {content=none}\n'), { ok: true, value: { type: 'doc', version: 1 } })
assert.deepEqual(markdownToAdf('\n!adf:doc {content=none}\n\n'), { ok: true, value: { type: 'doc', version: 1 } })
const form = 'unsupported-node-shape: doc spells the one form !adf:doc {content=none}: this one spells another'
assert.equal(content(markdownToAdf('!adf:doc\n')), form)
assert.equal(content(markdownToAdf('!adf:doc x {content=none}\n')), form)
assert.equal(content(markdownToAdf('!adf:doc {content=empty}\n')), form)
assert.equal(content(markdownToAdf('!adf:doc {content="none"}\n')), form)
const alone = 'unsupported-node-shape: !adf:doc {content=none} spells a whole document holding no content key, alone: this one stands among other blocks'
assert.equal(content(markdownToAdf('x\n\n!adf:doc {content=none}\n')), alone)
assert.equal(content(markdownToAdf('!adf:panel info\n!adf:doc {content=none}\n!adf:/panel\n')), alone)
assert.equal(content(markdownToAdf('!adf:doc\n!adf:/doc\n')), 'malformed-directive: doc takes no body, so no !adf:/doc closes it; \\!adf: keeps the prefix literal')
assert.equal(content(markdownToAdf('a !adf:doc{content=none}\n')), 'unsupported-node-shape: doc takes the block form, !adf:doc, never the inline form')
})
test('builds one paragraph from the lines a blank line does not part', () => {
@@ -153,7 +165,7 @@ test('names the slot a codeBlock spells its language outside of', () => {
const slot = 'unsupported-node-shape: codeBlock spells its language in the fence info string, or in the attribute where no info string carries it back'
assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust wrap=true}\n```\nx\n```\n!adf:/codeBlock\n')), slot)
assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust}\n```sql\nx\n```\n!adf:/codeBlock\n')), slot)
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```carry\nx\n```\n!adf:/codeBlock\n')), slot)
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```adf:x\nx\n```\n!adf:/codeBlock\n')), slot)
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```a\\b\nx\n```\n!adf:/codeBlock\n')), slot)
})
@@ -343,18 +355,26 @@ test('names the position a directive name the other one spells belongs to', () =
})
test('names the reserved carry name a block directive spells', () => {
const reserved = 'malformed-directive: the name carry is reserved for the opaque carry, whose block form is the carry fence'
const reserved = 'malformed-directive: the name carry is reserved for the opaque carry, whose block form is the adf: fence'
assert.equal(content(markdownToAdf('!adf:carry\n')), reserved)
assert.equal(content(markdownToAdf('!adf:carry\nx\n!adf:/carry\n')), reserved)
assert.deepEqual(content(markdownToAdf('```adf\nx\n```\n')), [{ attrs: { language: 'adf' }, content: [text('x')], type: 'codeBlock' }])
assert.deepEqual(content(markdownToAdf('```carry\nx\n```\n')), [{ attrs: { language: 'carry' }, content: [text('x')], type: 'codeBlock' }])
})
const carried = '!adf:carry{json="{\\"type\\":\\"placeholder\\"}"}'
test('reads the carry fence back to the node its JSON holds', () => {
assert.deepEqual(content(markdownToAdf('```carry\n{\n "attrs": {\n "url": "https://example.com/x"\n },\n "type": "blockCard"\n}\n```\n')), [
test('reads the carry fence back to the node its info string names and its JSON holds', () => {
assert.deepEqual(content(markdownToAdf('```adf:blockCard\n{\n "attrs": {\n "url": "https://example.com/x"\n }\n}\n```\n')), [
{ attrs: { url: 'https://example.com/x' }, type: 'blockCard' },
])
assert.deepEqual(content(markdownToAdf('```adf:\n{\n "type": "a`b"\n}\n```\n')), [{ type: 'a`b' }])
assert.equal(
content(markdownToAdf('```adf:blockCard\n{\n "type": "blockCard"\n}\n```\n')),
"unsupported-node-shape: the adf:blockCard fence names its node's type: this JSON holds a type as well",
)
assert.equal(content(markdownToAdf('```adf:\n{\n "type": "blockCard"\n}\n```\n')), 'unsupported-node-shape: the carry fence names a type its info string carries: spell it adf:blockCard')
assert.equal(content(markdownToAdf('```adf:\n{}\n```\n')), "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node")
})
test('reads the inline carry back to the node its json attribute holds', () => {
@@ -365,22 +385,22 @@ test('reads the inline carry back to the node its json attribute holds', () => {
test('names the invalid JSON no opaque carry holds', () => {
const invalid = 'malformed-directive: the opaque carry holds invalid JSON'
assert.equal(content(markdownToAdf('```carry\n{"type":\n```\n')), invalid)
assert.equal(content(markdownToAdf('```carry\n```\n')), invalid)
assert.equal(content(markdownToAdf('```adf:x\n{"attrs":\n```\n')), invalid)
assert.equal(content(markdownToAdf('```adf:x\n```\n')), invalid)
assert.equal(content(markdownToAdf('!adf:carry{json="{"}\n')), invalid)
assert.equal(content(markdownToAdf('!adf:carry{json=abc}\n')), invalid)
})
test('names the canonical spelling a carried JSON reads alone', () => {
const canonically = "unsupported-node-shape: the opaque carry spells its node's JSON canonically: "
assert.equal(content(markdownToAdf('```carry\n{"type":"blockCard"}\n```\n')), `${canonically}two-space indent, keys sorted`)
assert.equal(content(markdownToAdf('```adf:blockCard\n{"attrs":{}}\n```\n')), `${canonically}two-space indent, keys sorted`)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted`)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted`)
})
test('names the node JSON an opaque carry restores alone', () => {
const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node"
assert.equal(content(markdownToAdf('```carry\n[]\n```\n')), node)
assert.equal(content(markdownToAdf('```adf:x\n[]\n```\n')), node)
assert.equal(content(markdownToAdf('!adf:carry{json=null}\n')), node)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"kind\\":\\"x\\"}"}\n')), node)
})
@@ -394,7 +414,7 @@ test('names the shape the inline carry reads alone', () => {
test('holds a carried JSON value to the nesting its position leaves', () => {
const nested = (levels: number): string => `${'['.repeat(levels)}${']'.repeat(levels)}`
const fence = (prefix: string, levels: number): string => `${prefix}\`\`\`carry\n${prefix}${nested(levels)}\n${prefix}\`\`\`\n`
const fence = (prefix: string, levels: number): string => `${prefix}\`\`\`adf:x\n${prefix}${nested(levels)}\n${prefix}\`\`\`\n`
const deeper = (levels: number): string => `unsupported-nesting-depth: a carried node's JSON nests deeper than the ${levels} levels its position leaves`
assert.equal(content(markdownToAdf(`!adf:carry{json="${nested(largestNesting + 2)}"}\n`)), deeper(largestNesting))
assert.equal(
@@ -407,11 +427,11 @@ test('holds a carried JSON value to the nesting its position leaves', () => {
test('names the number no JSON spelling carries in an opaque carry', () => {
const named = 'unsupported-node-shape: the opaque carry holds a number JSON cannot spell'
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"attrs\\":{\\"width\\":1e999},\\"type\\":\\"blockCard\\"}"}\n')), named)
assert.equal(content(markdownToAdf('```carry\n1e999\n```\n')), named)
assert.equal(content(markdownToAdf('```adf:x\n1e999\n```\n')), named)
})
test('names the mark spelling no opaque carry sits inside', () => {
const named = 'unsupported-node-shape: no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
const named = 'unsupported-node-shape: no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own'
assert.equal(content(markdownToAdf(`_a ${carried} b_\n`)), named)
assert.equal(content(markdownToAdf(`**${carried}**\n`)), named)
assert.equal(content(markdownToAdf(`~~a ${carried}~~\n`)), named)
@@ -454,7 +474,9 @@ test('names the marks key no marks array reads back from', () => {
assert.equal(content(markdownToAdf('!adf:rule {marks="[1]"}\n')), named)
assert.equal(content(markdownToAdf('!adf:rule {marks="{}"}\n')), named)
assert.equal(content(markdownToAdf('!adf:rule {marks=x}\n')), named)
assert.equal(content(markdownToAdf('!adf:rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), named)
assert.equal(content(markdownToAdf('!adf:rule {marks="[{\\"type\\":\\"em\\",\\"attrs\\":{}}]"}\n')), named)
assert.deepEqual(content(markdownToAdf('!adf:rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), [{ marks: [{ attrs: {}, type: 'em' }], type: 'rule' }])
assert.deepEqual(content(markdownToAdf('!adf:rule {marks=empty}\n')), [{ marks: [], type: 'rule' }])
})
test('names the attribute a node holds no reading for', () => {
@@ -487,7 +509,8 @@ test('names the argument and the body a node takes no reading for', () => {
assert.equal(content(markdownToAdf('!adf:rule x\n')), 'unsupported-node-shape: rule takes no argument: this one spells one')
assert.equal(content(markdownToAdf('!adf:paragraph\nOne.\n\nTwo.\n!adf:/paragraph\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one')
assert.equal(content(markdownToAdf('!adf:paragraph\n---\n!adf:/paragraph\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one')
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\nx\n!adf:/codeBlock\n')), 'unsupported-node-shape: codeBlock takes one code block as its body: this body is not one')
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\nx\n!adf:/codeBlock\n')), 'unsupported-node-shape: codeBlock takes code blocks as its body: this body holds another block')
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n!adf:/codeBlock\n')), 'unsupported-node-shape: codeBlock takes code blocks as its body: this body holds none')
assert.equal(content(markdownToAdf('!adf:paragraph\n![a](/u)\n!adf:/paragraph\n')), 'unmappable-image: no ADF node carries an image inside a paragraph')
assert.equal(content(markdownToAdf('Part !adf:date[now]{timestamp=1}.\n')), 'unsupported-node-shape: date takes no content: this one holds some')
})
@@ -545,7 +568,7 @@ test('names the line and the offset in the input a refusal sits at, the innermos
assert.deepEqual(position(markdownToAdf('- Part.\n- a <span>b</span>\n')), { line: 2, offset: 8 })
assert.deepEqual(position(markdownToAdf('Part.\n\n!adf:panel info\nMore.\n')), { line: 3, offset: 7 })
assert.deepEqual(position(markdownToAdf('x\n\na <span>b</span>\n===\n')), { line: 3, offset: 3 })
assert.deepEqual(position(markdownToAdf('x\n\n```carry\n{\n```\n')), { line: 3, offset: 3 })
assert.deepEqual(position(markdownToAdf('x\n\n```adf:x\n{\n```\n')), { line: 3, offset: 3 })
assert.deepEqual(position(markdownToAdf('x\n\n| a |\n')), { line: 3, offset: 3 })
assert.deepEqual(position(markdownToAdf('a\nb <span>c</span>\n')), { line: 1, offset: 0 })
assert.deepEqual(position(markdownToAdf('Part.\r\n\r\n<div>\r\n')), { line: 3, offset: 9 })
+36 -12
View File
@@ -5,14 +5,14 @@ import type { ConvertFault } from '../../result.ts'
import type { Flavour } from '../plain-conventions.ts'
import type { LineContainer } from '../line-container.ts'
import type { LinkDefinitions } from './inline-content.ts'
import { carryName, readCarriedBlock } from '../opaque-carry.ts'
import { carryFencePrefix, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts'
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
import { inlineLeaves } from '../emit/plain-inline.ts'
import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain-conventions.ts'
import { listBreakName, listBreakSpelling } from '../block-directive.ts'
import { mintTaskIds } from './task-ids.ts'
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
import { parseBlocks } from './blocks.ts'
@@ -39,10 +39,15 @@ export function plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseE
function readDocument(markdown: string, flavour: Flavour): Result<AdfDocument, ParseError> {
const parsed = parseBlocks(markdown)
const [only] = parsed.blocks
if (parsed.blocks.length === 1 && only?.kind === 'directive' && only.name === documentName) {
const fault = documentFault(only)
return fault === undefined ? success({ type: 'doc', version: 1 }) : positioned(faulted(fault, []), only.position)
}
const reading: Reading = { carried: new Set(), definitions: parsed.definitions, flavour, inExpand: false, memo: new Map() }
const content = positioned(readBlocks(parsed.blocks, reading, [], 0), documentStart)
if (!content.ok) return content
const document: AdfDocument = content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 }
const document: AdfDocument = { content: content.value, type: 'doc', version: 1 }
if (flavour === 'plain') mintTaskIds(document, markdown, reading.carried)
return success(document)
}
@@ -52,6 +57,10 @@ function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErr
const content: AdfNode[] = []
for (const [index, block] of blocks.entries()) {
const nodePath = [...path, 'content', content.length]
if (block.kind === 'directive' && block.name === documentName) {
const fault = documentFault(block) ?? unsupportedNodeShape(`${documentSpelling} spells a whole document holding no content key, alone: this one stands among other blocks`)
return positioned(faulted(fault, nodePath), block.position)
}
if (block.kind === 'directive' && block.name === listBreakName) {
const fault = listBreakFault(block, blocks[index - 1], blocks[index + 1])
if (fault !== undefined) return positioned(faulted(fault, nodePath), block.position)
@@ -73,6 +82,12 @@ function listBreakFault(block: DirectiveBlock, previous: Block | undefined, next
return previous.kind === next?.kind ? undefined : partsFault()
}
function documentFault(block: DirectiveBlock): ConvertFault | undefined {
const content = block.attributes.get('content')
if (block.argument === undefined && block.attributes.size === 1 && content?.spelling === 'none') return undefined
return unsupportedNodeShape(`${documentName} spells the one form ${documentSpelling}: this one spells another`)
}
function partsFault(): ConvertFault {
return unsupportedNodeShape(`${listBreakName} parts two adjacent lists of one type: this one parts something else`)
}
@@ -214,24 +229,33 @@ function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErr
}
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const { contentModel, node } = read
const { contentModel, emptyContent, node } = read
if (blocks === undefined) return success(node)
if (emptyContent) return blocks.length === 0 ? success(node) : failure('unsupported-node-shape', `${node.type} spells content=empty, which holds no body: this one holds one`, path)
if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path)
return containerNode(node, blocks, reading, path, depth)
}
// spec/flavour.md, The CommonMark blocks: one fence per text node, every fence carrying the one language.
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> {
const only = blocks.length === 1 ? blocks[0] : undefined
if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one code block as its body: this body is not one`, path)
const fences: Extract<Block, { kind: 'code' }>[] = []
for (const block of blocks) {
if (block.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes code blocks as its body: this body holds another block`, path)
fences.push(block)
}
const [first] = fences
if (first === undefined) return failure('unsupported-node-shape', `${node.type} takes code blocks as its body: this body holds none`, path)
if (fences.some((fence) => fence.language !== first.language)) return failure('unsupported-node-shape', `${node.type} holds one language, so its fences carry one info string: these differ`, path)
if (fences.length > 1 && fences.some((fence) => fence.text === '')) return failure('unsupported-node-shape', `a fence beside another spells a text node, which holds text: this one is empty`, path)
const attribute = nodeAttrs(node)['language']
const fromFence = only.language !== ''
const slot = languageSlot(fromFence ? only.language : attribute)
const fromFence = first.language !== ''
const slot = languageSlot(fromFence ? first.language : attribute)
if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) {
return failure('unsupported-node-shape', `${node.type} spells its language in the fence info string, or in the attribute where no info string carries it back`, path)
}
const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: only.language } } : node
return success(withContent(spelled, only.text === '' ? [] : [{ text: only.text, type: 'text' }]))
const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: first.language } } : node
return success(withContent(spelled, first.text === '' ? [] : fences.map((fence): AdfNode => ({ text: fence.text, type: 'text' }))))
}
function tableNode(rows: readonly string[][], reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
@@ -278,8 +302,8 @@ function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, pa
}
function codeBlockNode(language: string, text: string, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
if (language === carryName) {
const carried = readCarriedBlock(text, depth)
if (language.startsWith(carryFencePrefix)) {
const carried = readCarriedBlock(language.slice(carryFencePrefix.length), text, depth)
if (carried.fault !== undefined) return faulted(carried.fault, path)
reading.carried.add(carried.value)
return success(carried.value)
+16
View File
@@ -0,0 +1,16 @@
import type { AdfNode } from '../adf/document.ts'
import { identicalMarks } from '../adf/document.ts'
import { spellInlineLeafDirective } from './directive-syntax.ts'
export const textBreakName = 'textBreak'
export const textBreakSpelling = spellInlineLeafDirective(textBreakName, '')
// Whether CommonMark reads the pair back as one text node, where neither rides the carry (spec/flavour.md, Inline nodes).
export function readsAsOne(previous: AdfNode, node: AdfNode): boolean {
return spelledAsText(previous) && spelledAsText(node) && identicalMarks(previous, node)
}
function spelledAsText(node: AdfNode): boolean {
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
}
+14 -10
View File
@@ -6,7 +6,7 @@
`Bar = 9`
`Next ID = 52`
`Next ID = 54`
| Goal | W |
|---|---|
@@ -24,13 +24,14 @@
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|---|---|---|---|---|---|---|---|---|---|
| 40 | 0.2.0 | decision | **Make `markdownToAdf(adfToMarkdown(doc))` deep-equal `doc` for every document `adfToMarkdown` takes.** | 6 | 7 | 8 | 9 | 1 | 26.2 |
| 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 3 | 25.8 |
| 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 |
| 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 3 | 24.7 |
| 43 | 0.2.0 | | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 3, 4 | 22.3 |
| 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists in the CommonMark reader.** | 4 | 5 | 5 | 8 | 3, 4 | 17.2 |
| 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 5 | 13.0 |
| 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 3, 4 | 12.4 |
| 53 | 0.2.0 | | **Bench a block's `marks` spelling with the writer panel and adopt its pick.** | 4 | 5 | 5 | 6 | 5 | 11.5 |
| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 3, 4, 6 | 10.4 |
| 38 | 0.3.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 4 | 7 | 1 | 19.5 |
| 47 | 0.3.0 | | **Open the README with what the package is, what it does and for whom.** | 1 | 4 | 7 | 9 | 9 | 14.0 |
@@ -45,14 +46,6 @@
## Details
### 40. Make `markdownToAdf(adfToMarkdown(doc))` deep-equal `doc` for every document `adfToMarkdown` takes.
Today it holds for editor-normal documents only: two adjacent text nodes with the same marks merge,
an empty `attrs`, `marks` or `content` drops, and `-0` reads back `0` — shapes pipelines and bots
build. Spell each so it reads back as written; CommonMark's spelling stays wherever the document
holds none of these shapes. The spellings are part of the chunk. `docs/decisions.md` §Equality is
editor-normal, `spec/flavour.md` and `corpus/README.md` follow, and the tests drop `toEditorNormal`.
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.
Lands after item 6. The CommonMark spec suite also runs against `markdownToHtml`. The README
@@ -90,6 +83,11 @@ Readings table gains its row, and its Spellings table one if `!adf:listBreak` re
and 302 lose their `pending` exceptions, and the spelling leaves the README's "Four CommonMark
spellings" bullet, which counts one fewer.
### 52. Spell `colwidth` as a comma list, `colwidth="340,420"`.
A writer panel chose it on 2026-10-03, 5 of 7, over today's `colwidth="[340,420]"`. Breaking:
`MIGRATION.md`'s Spellings table gains its row.
### 51. Match a reference label to its definition under Unicode case folding.
`link-syntax.ts` normalizes a label with `toLowerCase`, so `[ẞ]` misses its `[SS]` definition (spec
@@ -97,6 +95,12 @@ example 540); lowercasing and then uppercasing folds it. Breaking, so it ships b
`MIGRATION.md`'s Readings table gains its row. Its `pending` exceptions go, and its spelling leaves
the README's "Four CommonMark spellings" bullet, which counts one fewer.
### 53. Bench a block's `marks` spelling with the writer panel and adopt its pick.
Today `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"`, the marks array as
escaped JSON. Breaking where the panel picks another spelling: `MIGRATION.md`'s Spellings table
gains its row.
### 50. Read `[](/url)` and `[]()` as CommonMark's empty link.
Both stay literal text today (spec examples 484 and 487). ADF holds no empty text node to carry a