28 Commits

Author SHA1 Message Date
lilleman 423dcbbff5 File the audits' pre-existing findings, and mark item 43 cited by decisions
CI / gate (push) Successful in 53s
CI / publish (push) Has been skipped
2026-10-03 14:47:57 +02:00
lilleman b9760d0af2 Name plainMarkdownToAdf in the empty-markdown changelog entry
CI / gate (push) Successful in 52s
CI / publish (push) Has been skipped
2026-10-03 14:46:29 +02:00
lilleman d7ba8e6aae Keep format knowledge out of mergeAdjacentText's comment 2026-10-03 14:46:29 +02:00
lilleman bba2fa409e Drop empty keys in the property harness through emptyKeys 2026-10-03 14:46:29 +02:00
lilleman c3bc2eb572 Read a carry fence's type in one function 2026-10-03 14:45:48 +02:00
lilleman 2700184e56 Spell the doc directive's content=none through spellAttributes from one constant 2026-10-03 14:45:28 +02:00
lilleman d9c3e4b349 Derive a block directive's empty content from its node, and let readEmptyKeys own the attrs=empty-stands-alone rule 2026-10-03 14:45:12 +02:00
lilleman 2d02165702 Keep the browser leg on dist/index.js: tag -0 in the JSON it hands back 2026-10-03 14:44:14 +02:00
lilleman a565d49c24 Spell 'a text node holding its text and marks alone' once, as isPlainText 2026-10-03 14:44:00 +02:00
lilleman ade3997cfd Hold a carried node's own marks on the item, not in a side set copies fall out of 2026-10-03 14:43:03 +02:00
lilleman 31576bb824 Take the raw run's refusal path only where its code matches the editor-normal refusal 2026-10-03 14:41:42 +02:00
lilleman 723c0e31ef Name the adf: fence among the carve-outs, and the way a fence stays code in its errors 2026-10-03 14:41:30 +02:00
lilleman a5238e4fe5 Cite item 43 as what ends the CommonMark carve-outs and the adf: fence reservation 2026-10-03 14:40:31 +02:00
lilleman 9f0f520afd Give the comprehension panel its fill-ins
CI / gate (push) Successful in 52s
CI / publish (push) Has been skipped
2026-10-03 14:34:28 +02:00
lilleman 45f1bee94b Set up every inline scan through one freshScan
CI / gate (push) Successful in 53s
CI / publish (push) Has been skipped
2026-10-03 14:33:06 +02:00
lilleman 20a191e7e9 Write the same plain markdown for documents the editor holds equal: normalize the reduction's input again, naming a refusal by the caller's document 2026-10-03 14:32:34 +02:00
lilleman 3da45a9da7 File item 54: the source-parting decision misses an import parse/ takes from emit/
CI / gate (push) Successful in 45s
CI / publish (push) Has been skipped
2026-10-03 14:23:13 +02:00
lilleman 0bc19238b4 Say only the plain writer is editor-normal, and read plain markdown in the tests without normalizing 2026-10-03 14:23:04 +02:00
lilleman 1458b09449 Index a plain refusal's path in the caller's document: normalize only the reduction's output 2026-10-03 14:22:14 +02:00
lilleman eab28ae8ff Refuse a carry fence naming a type no info string carries back 2026-10-03 14:20:47 +02:00
lilleman afb53ca594 Keep an empty stored document empty in the migration recipe 2026-10-03 14:20:03 +02:00
lilleman 70e2639982 Carry the text break as its own item, not a fake node, and check it where the outermost content resolves 2026-10-03 14:19:55 +02:00
lilleman fdbf43398c Keep one merge loop and one mark key: editor-normal compares normalized marks with identicalMark 2026-10-03 14:17:22 +02:00
lilleman 0c507f981f Name the writer panel behind the doc spelling
CI / gate (push) Successful in 1m46s
CI / publish (push) Has been skipped
2026-10-03 12:31:49 +02:00
lilleman 1d0ae695da Name the code block the carry now holds in the migration's error codes 2026-10-03 12:31:10 +02:00
lilleman f180943edf Record deep equality, the text break, empty keys, -0, the typed carry fence and per-node code fences; file items 52 and 53 2026-10-03 12:28:20 +02:00
lilleman 9ab7286e6d Make markdownToAdf(adfToMarkdown(doc)) deep-equal doc: textBreak, empty keys, -0, typed carry fences, code blocks of several text nodes 2026-10-03 12:25:01 +02:00
lilleman bfc4b2ce00 Name the markdown's readers and writers in the audience, and the panel rule 2026-10-03 12:03:20 +02:00
92 changed files with 1734 additions and 418 deletions
+23 -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
@@ -180,3 +190,14 @@ and `todo.md` and trusting them over anything remembered from earlier iterations
is a thin driver: each chunk's work runs in a fresh-context subagent holding this file as its
charter, and the driver only relays maintainer questions, runs the review flow, merges, and cleans
up. The loop stops when only maintainer-reserved acts remain.
## 8. Scoring run
The comprehension panel's fill-ins:
- Language: TypeScript.
- Kind: a pure-function document converter with hand-written parsers and emitters.
- Domain: Atlassian Document Format and CommonMark parsing.
- Domain docs: the CommonMark spec, ADF's JSON schema and `spec/flavour.md`.
- 3am question: a viewer/editor app reports that a document it saved comes back with two text
nodes merged and a mark gone after `markdownToAdf(adfToMarkdown(doc))`.
+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` and `plainMarkdownToAdf` read 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
+7 -3
View File
@@ -22,7 +22,8 @@ import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1'
function migrateMarkdown(stored: string) {
const parsed = markdownToAdf010(stored)
return parsed.ok ? adfToMarkdown(parsed.value) : parsed
// 0.1.0's reader was editor-normal, so a document with no content key always meant an empty one.
return parsed.ok ? adfToMarkdown({ ...parsed.value, content: parsed.value.content ?? [] }) : parsed
}
```
@@ -40,12 +41,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 +55,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 +66,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` |
+12 -5
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 keep it literal: escape the prefix — `\!adf:`, block and inline alike — or wrap a code fence whose info string opens `adf:` in `!adf:codeBlock {language="adf:…"}` with a bare fence |
| `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,14 +204,17 @@ 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
name every exception.
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html`
names, with three carve-outs — literal text matching directive, pipe-table or strikethrough
syntax is claimed (escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as
names, with four carve-outs — literal text matching directive, pipe-table or strikethrough
syntax, and a code fence whose info string opens `adf:`, is claimed (escapable — `spec/flavour.md`)
— and one gap: a CommonMark image fits only as
its own title-less paragraph; mid-text and titled images are error results, save an image inside
another's description, which flattens into the alt text. Converting back yields the library's
canonical spelling, which round-trips byte-identically — where it converts back at all: a parse
@@ -235,7 +242,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.
+5 -3
View File
@@ -1,16 +1,18 @@
try {
const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js')
// WebDriver's JSON reads -0 back as 0, so a document crosses as JSON text with -0 tagged; run.js revives it.
const spelled = (result) => (result.ok ? { ok: true, value: JSON.stringify(result.value, (_, value) => (Object.is(value, -0) ? '\u0000-0' : value)) } : 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) {
+8 -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'
@@ -50,6 +49,11 @@ function fixtureNames(kind, extension) {
.sort()
}
// convert-corpus.js tags -0 so it survives WebDriver's JSON.
function revived(text) {
return JSON.parse(text, (_, value) => (value === '\u0000-0' ? -0 : value))
}
function refusal(result) {
return result.ok ? '' : `${result.error.code}: ${result.error.message}`
}
@@ -107,7 +111,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.deepEqual(revived(result.parsed.value), JSON.parse(json))
})
}
@@ -115,7 +119,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.deepEqual(revived(result.parsed.value), JSON.parse(fixture(name, '.json')))
})
}
@@ -125,7 +129,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.deepEqual(revived(result.parsed.value), JSON.parse(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"
}
```
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,3 @@
```adf:\\
{}
```
+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
@@ -0,0 +1 @@
!adf:status[!adf:carry{json="{\"marks\":[],\"text\":\"x\",\"type\":\"text\"}"}]
@@ -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
{}
```
+96 -17
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,103 @@ 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 reader
builds what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction
reads and writes editor-normal ADF — adjacent text nodes of identical marks and no attributes
merged, `-0` as `0`, and an empty `attrs`, `content` or `marks` the absent key — so two documents
the editor holds equal write the same plain markdown.
## `!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`. The
reservation claims a fence CommonMark reads as code until `todo.md` item 43 gives CommonMark its own
reader.
## 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
@@ -97,7 +173,9 @@ opener nests by itself and leaf versus container falls out of the node's content
2026-08-23, the maintainer. Goal 4. Valid while prose rarely writes the shapes the carve-outs claim.
Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a
directive, a pipe table or a `~~` pair is claimed — plus one image gap.
directive, a pipe table or a `~~` pair is claimed, and so is a code fence whose info string opens
`adf:` (§The carry fence names the node type) — plus one image gap. `todo.md` item 43 ends the
claims, giving CommonMark its own reader.
## Tables
@@ -295,10 +373,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
+70 -39
View File
@@ -1,9 +1,10 @@
# The markdown flavour
The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain
CommonMark is a subset apart from raw HTML (below), with three carve-outs: literal text that matches
directive syntax below or reads as a pipe table is claimed by the flavour, and a matched `~~` pair
spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal) — and one gap: a CommonMark
CommonMark is a subset apart from raw HTML (below), with four carve-outs: literal text that matches
directive syntax below or reads as a pipe table is claimed by the flavour, a matched `~~` pair
spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal), and a code fence whose info
string opens `adf:` is the opaque carry (The opaque carry says how to keep it code) — and one gap: a CommonMark
image fits only as its own title-less paragraph — mid-text and titled images are named errors. The
emitted form is contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this
grammar in the sections below.
@@ -44,7 +45,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 +69,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 +84,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 +149,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 +177,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 +210,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 +233,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 +445,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 +473,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 +521,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
+46 -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,45 @@ 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
}
// A text node holding its text and marks alone: a format spells it as text, where anything more rides a carry.
export function isPlainText(node: AdfNode): boolean {
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
}
export function identicalMark(left: AdfMark, right: AdfMark): boolean {
return marksKey([left]) === marksKey([right])
}
export function identicalMarks(left: readonly AdfMark[], right: readonly AdfMark[]): boolean {
return marksKey(left) === marksKey(right)
}
// Callers say when two nodes join.
export function mergeAdjacentText(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): AdfNode[] {
const merged: AdfNode[] = []
for (const node of nodes) {
const previous = merged[merged.length - 1]
if (previous !== undefined && joins(previous, node)) {
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
continue
}
merged.push(node)
}
return merged
}
// 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 +137,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) {
+7 -28
View File
@@ -1,34 +1,25 @@
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './document.ts'
import type { JsonValue } from '../json-value.ts'
import { nodeAttrs, nodeContent, nodeMarks } from './document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
import { identicalMark, identicalMarks, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from './document.ts'
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
type NodeHolder = { content?: AdfNode[] }
export function sameMark(candidate: AdfMark, mark: AdfMark): boolean {
return markKey(candidate) === markKey(mark)
export function sameMark(left: AdfMark, right: AdfMark): boolean {
return identicalMark(normalMark(left), normalMark(right))
}
export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] {
const merged: AdfNode[] = []
for (const node of nodes) {
const previous = merged[merged.length - 1]
if (previous !== undefined && mergesText(previous) && mergesText(node) && sameMarks(previous, node)) {
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
continue
}
merged.push(node)
}
return merged
export function joinsNormally(previous: AdfNode, node: AdfNode): boolean {
if (!mergesText(previous) || !mergesText(node)) return false
return identicalMarks(nodeMarks(previous).map(normalMark), nodeMarks(node).map(normalMark))
}
export function toEditorNormal(document: AdfDocument): AdfDocument {
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
const content = mergeAdjacentText(nodeContent(entry.source))
const content = mergeAdjacentText(nodeContent(entry.source), joinsNormally)
if (content.length === 0) continue
entry.holder.content = content.map((source) => {
const holder = normalNode(source)
@@ -76,15 +67,3 @@ function normalValue(value: JsonValue, pending: JsonContainer[]): JsonValue {
function mergesText(node: AdfNode): boolean {
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
}
export function sameMarks(previous: AdfNode, node: AdfNode): boolean {
return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node))
}
function marksKey(marks: readonly AdfMark[]): string {
return marks.map(markKey).join('\n')
}
function markKey(mark: AdfMark): string {
return `${mark.type} ${serializeCanonicalJson(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('')
}
+10 -1
View File
@@ -41,7 +41,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),
)
@@ -62,3 +62,12 @@ test('a generated document writes plain markdown refusing only what the guard re
propertyRuns(gateRuns),
)
})
test('a generated document writes the plain markdown its editor-normal form writes', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (document) => {
assert.deepEqual(adfToPlainMarkdown(document), adfToPlainMarkdown(toEditorNormal(document)))
}),
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 -20
View File
@@ -2,16 +2,17 @@ import assert from 'node:assert/strict'
import { env } from 'node:process'
import fc from 'fast-check'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../adf/document.ts'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode, EmptyKey } from '../adf/document.ts'
import type { Arbitrary } from 'fast-check'
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { JsonValue } from '../json-value.ts'
import { blockArgument } from '../markdown/block-directive.ts'
import { blockNodes } from '../adf/block-nodes.ts'
import { directivePrefix } from '../markdown/directive-syntax.ts'
import { emptyKeys, mergeAdjacentText } from '../adf/document.ts'
import { inlineNodes } from '../adf/inline-nodes.ts'
import { joinsNormally } from '../adf/editor-normal.ts'
import { markAttributes } from '../adf/mark-attributes.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
type Positions = { block: AdfNode; inline: AdfNode }
@@ -36,7 +37,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 +78,35 @@ 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: Partial<Record<EmptyKey, unknown>> & T = { ...held }
for (const key of emptyKeys(held)) delete kept[key]
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, joinsNormally)))
}
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 +117,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 +126,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 +137,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 +193,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]
+11 -7
View File
@@ -2,9 +2,9 @@ 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'
import { spellAttributes, spellDirectiveOpener } from './directive-syntax.ts'
const argumentByType = new Map(
Object.entries({
@@ -14,6 +14,13 @@ 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 documentAttribute = { key: 'content', value: 'none' } as const
export const documentSpelling = spellDirectiveOpener(documentName, undefined, spellAttributes([[documentAttribute.key, documentAttribute.value]]))
export const listBreakName = 'listBreak'
export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '')
@@ -25,17 +32,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 { carryFenceType } 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' || carryFenceType(language) !== undefined || !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, isPlainText, 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) => !isPlainText(child) || 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 { identicalMark, isPlainText, 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,9 +268,9 @@ 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 (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 (!isPlainText(node)) 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 (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
@@ -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 (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
text += node.text
}
if (node.type === 'text' && nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
if (!isPlainText(node) || nodeMarks(node).length !== depth + 1) 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 (/[\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}`)] })
spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`)
}
return success({ segments: [syntax(spans.join(textBreakSpelling))] })
}
function needsPadding(text: string): boolean {
+3 -3
View File
@@ -3,9 +3,9 @@ import type { LineContainer } from '../line-container.ts'
import type { MarkRun } from './line-escaping.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { joinsNormally, sameMark } from '../../adf/editor-normal.ts'
import { largestNesting } from '../../nesting.ts'
import { mergeAdjacentText, sameMark } from '../../adf/editor-normal.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { plainLineFallback, type PlainLineFallback } from './inline-line.ts'
import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
@@ -193,7 +193,7 @@ function withMarks(leaf: AdfNode, marks: readonly AdfMark[]): AdfNode {
function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] {
for (let current = leaves; ; ) {
const merged = withoutEdgeBreaks(mergeAdjacentText(current))
const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsNormally))
let changed = false
const trimmed: AdfNode[] = []
for (const [index, leaf] of merged.entries()) {
+8 -1
View File
@@ -64,6 +64,7 @@ test('refuses what the document guard refuses, and nothing else', () => {
let deep: AdfNode = said('x')
for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'layoutColumn' }
assert.match(plainDocument(document(deep)), /^unsupported-nesting-depth at \/content\/0(\/content\/0)+$/)
assert.match(plainDocument(document(paragraph(text('a'), text('b')), text('c'), text('d'), deep)), /^unsupported-nesting-depth at \/content\/3(\/content\/0)+$/)
let deepInline: AdfNode = text('x')
for (let level = 0; level <= largestNesting; level += 1) deepInline = { content: [deepInline], type: 'unknownInline' }
assert.match(plainDocument(document(paragraph(deepInline))), /^unsupported-nesting-depth at /)
@@ -177,7 +178,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')
@@ -337,3 +338,9 @@ test('keeps the nodes the plain flavour spells and degrades only what it cannot'
const reduced = reduceToPlain(document(node('panel', { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType: 'info' }, said('x')), tasks))
assert.deepEqual(reduced.ok ? reduced.value : undefined, document(node('panel', { panelType: 'info' }, said('x')), { content: [node('taskItem', { state: 'DONE' }, text('t'))], type: 'taskList' }))
})
test('writes what the editor-normal form of the document writes', () => {
assert.equal(plain({ attrs: { order: -0 }, content: [{ content: [], type: 'listItem' }], type: 'orderedList' }), '0.\n')
assert.equal(plain({ content: [], text: 'hi', type: 'futureInline' }), 'hi\n')
assert.equal(plain(paragraph({ marks: [{ attrs: {}, type: 'code' }], text: 'a', type: 'text' }, { marks: [{ type: 'code' }], text: '|b', type: 'text' })), '`a|b`\n')
})
+12 -4
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,14 @@ 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 reduction reads and writes editor-normal ADF.
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
if (!blocks.ok) {
// Merging text renumbers siblings, so the caller's document names the path of the refusal the editor-normal one meets.
const raw = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
return !raw.ok && raw.error.code === blocks.error.code ? raw : blocks
}
return success({ content: nodeContent(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 })).slice(), type: 'doc', version: 1 })
}
function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
@@ -84,7 +91,7 @@ function reduceStanding(node: AdfNode, reduction: Reduction): Result<AdfNode[]>
function standsInline(node: AdfNode): boolean {
if (node.type === 'text' || inlineNodeModel(node.type) !== undefined) return true
return !isBlockNodeType(node.type) && node.content === undefined
return !isBlockNodeType(node.type) && nodeContent(node).length === 0
}
function reduceBody(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
@@ -262,7 +269,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[] {
+32
View File
@@ -0,0 +1,32 @@
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
}
// `held` is whether the directive spells an attribute outside {attrs}: an argument or a content slot.
export function readEmptyKeys(type: string, attributes: DirectiveAttributes, keys: readonly EmptyKey[], held: boolean): 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)
}
if (empty.has('attrs') && (held || [...rest.keys()].some((key) => key !== 'marks'))) return { fault: unsupportedNodeShape(`${type} spells attrs=empty beside an attribute it holds`) }
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])
}
+48 -15
View File
@@ -1,32 +1,49 @@
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 { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, 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)
// The type a fence's info string names, `undefined` where the info string opens no carry.
export function carryFenceType(info: string): string | undefined {
return info.startsWith(carryFencePrefix) ? info.slice(carryFencePrefix.length) : undefined
}
export function readCarriedBlock(type: string, body: string, depth: number): Read<AdfNode> {
if (type !== '' && !infoStringCarries(type)) {
return { fault: unsupportedNodeShape(`the carry fence names a type no info string carries back: spell it ${carryFencePrefix} with the type in the JSON`) }
}
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 +53,44 @@ 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 wayOut = type === undefined ? directiveEscape : fenceEscape(type)
const parsed = parseJsonText(raw)
if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') }
if (parsed === undefined) return { fault: malformedDirective(`the opaque carry holds invalid JSON; ${wayOut}`) }
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}`) }
return { fault: unsupportedNodeShape(`the opaque carry spells its node's JSON canonically: ${shape}; ${wayOut}`) }
}
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; ${wayOut}`) }
return { value: held }
}
// A code fence the carry claims stays code under the directive, whose language rides the attribute.
function fenceEscape(type: string): string {
return `${spellDirectiveOpener('codeBlock', undefined, spellAttributes([['language', spellStringAttribute(`${carryFencePrefix}${type}`)]]))} around a bare fence keeps it a code block`
}
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(name, attributes, ['attrs'], false)
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 success({ attrs: {}, type: name })
return success(Object.keys(attrs.value).length === 0 ? { type: name } : { attrs: attrs.value, type: name })
}
+23 -15
View File
@@ -1,18 +1,20 @@
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'
import type { Elsewhere } from './directive-attributes.ts'
import { attributeNestingMessage, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { attributeNestingMessage, isPlainText } from '../../adf/document.ts'
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 }
@@ -24,12 +26,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(name, attributes, spellsEmpty(spelled) ? ['attrs', 'content', 'marks'] : ['attrs', 'content'], argument !== undefined)
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 +43,9 @@ 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) })
return success({ contentModel: model.contentModel, node: namedNode(name, attrs.value, marks.value, empty.value.empty) })
}
export function readInlineDirectiveNode(
@@ -55,7 +59,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(name, attributes, ['attrs', 'content', 'marks'], slot !== undefined && content !== undefined)
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 +72,14 @@ export function readInlineDirectiveNode(
if (spans !== undefined) return faulted(spans, path)
attrs.value[slot] = text
}
return success(namedNode(name, attrs.value, undefined))
return success(namedNode(name, attrs.value, undefined, empty.value.empty))
}
// 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` }
}
@@ -86,7 +92,7 @@ function blockSpellingFault(name: string): ConvertFault | undefined {
function slotText(content: readonly AdfNode[]): string | undefined {
if (content.length === 0) return ''
const only = content.length === 1 ? content[0] : undefined
if (only?.type !== 'text' || nodeMarks(only).length > 0 || Object.keys(nodeAttrs(only)).length > 0 || nodeContent(only).length > 0 || typeof only.text !== 'string') return undefined
if (only === undefined || !isPlainText(only) || only.marks !== undefined || typeof only.text !== 'string') return undefined
return only.text
}
@@ -100,7 +106,9 @@ 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>): AdfNode {
const node: AdfNode = Object.keys(attrs).length > 0 || empty.has('attrs') ? { attrs, type } : { type }
if (empty.has('content')) node.content = []
if (marks !== undefined || empty.has('marks')) node.marks = [...(marks ?? [])]
return node
}
+131 -44
View File
@@ -10,19 +10,29 @@ 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, mergeAdjacentText, 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[] }
// The text break leaf: it holds no marks, and stands between the nodes it parts until the outermost content checks and drops it.
type TextBreak = { kind: 'textBreak' }
// A node whose marks are its own — an opaque carry, or an inline node spelling marks=empty: no mark spelling wraps it, and it joins no neighbour.
type Carried = { kind: 'carry'; node: AdfNode }
type Inline = AdfNode | Carried | TextBreak
type Scanned = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: Inline[] }
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
@@ -33,9 +43,10 @@ type Pairing = EmphasisPairing<Run>
type Piece =
| Bracket
| { kind: 'carry'; node: AdfNode }
| Carried
| { alt: string; kind: 'image'; node: AdfNode }
| { kind: 'nodes'; nodes: AdfNode[] }
| { kind: 'nodes'; nodes: Inline[] }
| TextBreak
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
| { closes: boolean; kind: 'highlight'; opens: boolean }
@@ -56,21 +67,41 @@ type Scan = {
spans: NestedSpans
}
type SlotContent = { carry: boolean; nodes: AdfNode[] }
// What a directive's content slot inherits from the content holding it.
type SharedScan = Pick<Scan, 'definitions' | 'path'>
const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
type SlotContent = { carry: boolean; nodes: Inline[] }
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'
const linkInLink = 'no link wraps a link: the [content] this one marks already holds one'
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
const textBreak: TextBreak = { kind: 'textBreak' }
// The outermost content: only here does a text break see both its neighbours.
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
return parseInline(source, definitions, path, container, noSpans, flavour === 'plain')
const scan = freshScan(source, { definitions, path }, { container, highlights: flavour === 'plain', spans: noSpans })
const scanned = scanInline(scan)
if (!scanned.ok) return scanned
if (scanned.value.image !== undefined) return success({ image: scanned.value.image })
const nodes = partText(scanned.value.nodes, scan)
if (!nodes.ok) return nodes
if (scan.openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes.value, path)
if (!takesDirective.ok) return takesDirective
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path)
}
return success({ carry: scanned.value.carry, nodes: nodes.value })
}
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 freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'container' | 'highlights' | 'spans'>): Scan {
return { ...shared, ...own, deactivatedBefore: 0, openingSpellableLink: false, pending: '', pieces: [], source }
}
function scanInline(scan: Scan): Result<Scanned> {
const { source } = scan
let index = 0
while (index < source.length) {
switch (source.charAt(index)) {
@@ -103,7 +134,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)
}
@@ -207,6 +238,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
return success({ kind: 'carry', node: carried.value })
}
if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success(textBreak) : 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' }] })
@@ -214,9 +246,11 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
if (!slot.ok) return slot
const mark = readDirectiveMark(span.name, span.attributes, scan.path)
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)
const content = slot.value?.nodes
if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: a content slot holds plain text`, scan.path)
const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), 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] } : { kind: 'carry', node: node.value })
}
function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result<Piece> {
@@ -230,11 +264,11 @@ function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotC
}
// spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `assemble` asks the emitter.
function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[], index: number): Result<Piece> | undefined {
function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[], index: number): Result<Piece> | undefined {
const href = linkHref(nodeAttrs(mark))
if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.path)
if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.path)
if (commonMarkLink(nodeAttrs(mark), href, nodes, 0, scan.container === undefined) === undefined) return undefined
if (commonMarkLink(nodeAttrs(mark), href, adfNodes(nodes), 0, scan.container === undefined) === undefined) return undefined
if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path)
scan.openingSpellableLink = true
return undefined
@@ -242,7 +276,8 @@ 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 { definitions, path } = scan
const parsed = scanInline(freshScan(span.content, { definitions, path }, { 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)
@@ -258,20 +293,50 @@ function pushNode(scan: Scan, node: AdfNode): void {
scan.pieces.push({ kind: 'nodes', nodes: [node] })
}
function assemble(scan: Scan): Result<InlineContent> {
function assemble(scan: Scan): Result<Scanned> {
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 nodes = resolveNodes(scan.pieces, scan, true)
if (!nodes.ok) return nodes
if (scan.openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes.value, scan.path)
if (!takesDirective.ok) return takesDirective
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, scan.path)
}
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(items: readonly Inline[], scan: Scan): Result<AdfNode[]> {
const parted: AdfNode[] = []
for (const [index, item] of items.entries()) {
if (!isTextBreak(item)) {
parted.push(isNode(item) ? item : item.node)
continue
}
const previous = items[index - 1]
const next = items[index + 1]
if (previous === undefined || next === undefined || !isNode(previous) || !isNode(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 isNode(item: Inline): item is AdfNode {
return !('kind' in item)
}
function isTextBreak(item: Inline): item is TextBreak {
return 'kind' in item && item.kind === 'textBreak'
}
// The nodes the items hold, carried ones unwrapped and text breaks dropped.
function adfNodes(items: readonly Inline[]): AdfNode[] {
const nodes: AdfNode[] = []
for (const item of items) {
if (isNode(item)) nodes.push(item)
else if (!isTextBreak(item)) nodes.push(item.node)
}
return nodes
}
function holdsCarry(pieces: readonly Piece[]): boolean {
return pieces.some((piece) => piece.kind === 'carry')
}
@@ -285,8 +350,8 @@ function holdsLink(pieces: readonly Piece[]): boolean {
return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes))
}
function marksLink(nodes: readonly AdfNode[]): boolean {
return nodes.some((node) => nodeMarks(node).some((mark) => mark.type === 'link'))
function marksLink(nodes: readonly Inline[]): boolean {
return adfNodes(nodes).some((node) => nodeMarks(node).some((mark) => mark.type === 'link'))
}
function readDelimiterRun(scan: Scan, index: number): number {
@@ -383,7 +448,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 +476,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,10 +485,11 @@ 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
return success(nodes.value.map(altText).join(''))
if (nodes.value.some(isTextBreak)) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: an image description holds plain text`, scan.path)
return success(adfNodes(nodes.value).map(altText).join(''))
}
// spec/flavour.md, The CommonMark image: the description's plain text, the content slot included.
@@ -435,22 +501,39 @@ 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<Inline[]> {
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()))
}
// A text break and a carried node are walls: the text on either side joins only its own side.
function mergeText(items: readonly Inline[]): Inline[] {
const merged: Inline[] = []
let run: AdfNode[] = []
for (const item of items) {
if (isNode(item)) {
run.push(item)
continue
}
for (const node of mergeAdjacentText(run, readsAsOne)) merged.push(node)
merged.push(item)
run = []
}
for (const node of mergeAdjacentText(run, readsAsOne)) 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[] {
function pieceNodes(piece: Piece): Inline[] {
switch (piece.kind) {
case 'carry':
return [piece.node]
return [piece]
case 'highlight':
return [{ text: '', type: 'text' }]
case 'image':
@@ -461,6 +544,8 @@ function pieceNodes(piece: Piece): AdfNode[] {
return bracketNodes(piece)
case 'run':
return []
case 'textBreak':
return [piece]
}
}
@@ -473,7 +558,7 @@ function delimiterRuns(pieces: readonly Piece[]): Run[] {
}
// A run gives its delimiters up from the head closing and the tail opening; what is left between them is text.
function writeUnpaired(nodes: AdfNode[][], runs: readonly Run[], pairings: readonly Pairing[]): void {
function writeUnpaired(nodes: Inline[][], runs: readonly Run[], pairings: readonly Pairing[]): void {
const heads = new Map<Run, number>()
const tails = new Map<Run, number>()
for (const pairing of pairings) {
@@ -488,7 +573,7 @@ function writeUnpaired(nodes: AdfNode[][], runs: readonly Run[], pairings: reado
}
// Innermost pairing first, so prepending leaves the marks array outermost first (spec/flavour.md, Marks).
function markPairings(pieces: readonly Piece[], nodes: AdfNode[][], pairings: readonly Pairing[]): boolean {
function markPairings(pieces: readonly Piece[], nodes: Inline[][], pairings: readonly Pairing[]): boolean {
for (const pairing of pairings) {
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) {
@@ -499,12 +584,12 @@ function markPairings(pieces: readonly Piece[], nodes: AdfNode[][], pairings: re
return true
}
function highlightDelimiters(pieces: readonly Piece[], nodes: readonly AdfNode[][]): HighlightDelimiter[] {
function highlightDelimiters(pieces: readonly Piece[], nodes: readonly Inline[][]): HighlightDelimiter[] {
const found: HighlightDelimiter[] = []
let line = 0
let position = 0
for (const [index, piece] of pieces.entries()) {
const held = nodes[index] ?? []
const held = adfNodes(nodes[index] ?? [])
const [holder] = held
if (piece.kind === 'highlight' && holder !== undefined) {
found.push({ closes: piece.closes, holder, index, line, opens: piece.opens, position })
@@ -520,7 +605,7 @@ function highlightDelimiters(pieces: readonly Piece[], nodes: readonly AdfNode[]
}
// Each opener takes the next closer holding at least one character after it, both in one line and under the same marks.
function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][]): { closer: number; opener: number }[] {
function pairedHighlights(pieces: readonly Piece[], nodes: readonly Inline[][]): { closer: number; opener: number }[] {
const found = highlightDelimiters(pieces, nodes)
const paired: { closer: number; opener: number }[] = []
let closer = 0
@@ -531,7 +616,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(nodeMarks(opener.holder), nodeMarks(candidate.holder))) continue
paired.push({ closer: candidate.index, opener: opener.index })
resume = candidate.position + highlightDelimiter.length
}
@@ -539,9 +624,9 @@ function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][])
}
// Atlassian's schema refuses a highlight on code, a node holds one highlight, and a rebuilt node would lose its attributes.
function markHighlights(pieces: readonly Piece[], nodes: AdfNode[][], paired: readonly { closer: number; opener: number }[]): void {
function markHighlights(pieces: readonly Piece[], nodes: Inline[][], paired: readonly { closer: number; opener: number }[]): void {
for (const [index, piece] of pieces.entries()) {
if (piece.kind === 'highlight') nodes[index] = (nodes[index] ?? []).map((holder) => ({ ...holder, text: highlightDelimiter }))
if (piece.kind === 'highlight') nodes[index] = adfNodes(nodes[index] ?? []).map((holder) => ({ ...holder, text: highlightDelimiter }))
}
for (const { closer, opener } of paired) {
nodes[opener] = []
@@ -550,7 +635,8 @@ function markHighlights(pieces: readonly Piece[], nodes: AdfNode[][], paired: re
}
}
function highlighted(node: AdfNode): AdfNode {
function highlighted(node: Inline): Inline {
if (!isNode(node)) return node
const marks = nodeMarks(node)
if (node.type !== 'text' || Object.keys(nodeAttrs(node)).length > 0 || marks.some((mark) => mark.type === 'code' || mark.type === 'backgroundColor')) return node
return { ...node, marks: [editorHighlight, ...marks] }
@@ -562,8 +648,9 @@ function markType(character: string, used: number): string {
}
// A node cannot carry one mark type twice (docs/decisions.md §No schema validation).
function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] {
function applyMark(nodes: readonly Inline[], mark: AdfMark): Inline[] {
return nodes.map((node) => {
if (!isNode(node)) return node
const marks = nodeMarks(node)
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
})
+71 -27
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,28 @@ 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; !adf:codeBlock {language=\"adf:\"} around a bare fence keeps it a code block")
const unnamed = 'unsupported-node-shape: the carry fence names a type no info string carries back: spell it adf: with the type in the JSON'
assert.equal(content(markdownToAdf('```adf:\\\\\n{}\n```\n')), unnamed)
})
test('reads the inline carry back to the node its json attribute holds', () => {
@@ -363,26 +385,31 @@ 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:carry{json="{"}\n')), invalid)
assert.equal(content(markdownToAdf('!adf:carry{json=abc}\n')), invalid)
test('names the invalid JSON no opaque carry holds, and the spelling that keeps it literal', () => {
const invalid = 'malformed-directive: the opaque carry holds invalid JSON; '
const fence = `${invalid}!adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block`
assert.equal(content(markdownToAdf('```adf:x\n{"attrs":\n```\n')), fence)
assert.equal(content(markdownToAdf('```adf:x\n```\n')), fence)
assert.equal(content(markdownToAdf('!adf:carry{json="{"}\n')), `${invalid}\\!adf: keeps the prefix literal`)
assert.equal(content(markdownToAdf('!adf:carry{json=abc}\n')), `${invalid}\\!adf: keeps the prefix literal`)
})
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:carry{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted`)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted`)
const literal = '; \\!adf: keeps the prefix literal'
assert.equal(
content(markdownToAdf('```adf:blockCard\n{"attrs":{}}\n```\n')),
`${canonically}two-space indent, keys sorted; !adf:codeBlock {language="adf:blockCard"} around a bare fence keeps it a code block`,
)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted${literal}`)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted${literal}`)
})
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:carry{json=null}\n')), node)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"kind\\":\\"x\\"}"}\n')), node)
const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node; "
assert.equal(content(markdownToAdf('```adf:x\n[]\n```\n')), `${node}!adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block`)
assert.equal(content(markdownToAdf('!adf:carry{json=null}\n')), `${node}\\!adf: keeps the prefix literal`)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"kind\\":\\"x\\"}"}\n')), `${node}\\!adf: keeps the prefix literal`)
})
test('names the shape the inline carry reads alone', () => {
@@ -394,12 +421,12 @@ 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(
content(markdownToAdf(fence('', largestNesting + 1))),
"unsupported-node-shape: the opaque carry spells its node's JSON canonically: two-space indent, keys sorted",
'unsupported-node-shape: the opaque carry spells its node\'s JSON canonically: two-space indent, keys sorted; !adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block',
)
assert.equal(content(markdownToAdf(fence('> ', largestNesting + 1))), deeper(largestNesting - 1))
})
@@ -407,11 +434,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 +481,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 +516,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 +575,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 })
@@ -1053,3 +1083,17 @@ test('names the directive mark left without the content it wraps', () => {
assert.equal(content(markdownToAdf('!adf:underline[]\n')), named)
assert.equal(content(markdownToAdf('!adf:underline{}\n')), named)
})
test('reads the text break only between two text nodes CommonMark joins, building no node', () => {
assert.deepEqual(content(markdownToAdf('a!adf:textBreak{}b\n')), [{ content: [text('a'), text('b')], type: 'paragraph' }])
assert.deepEqual(content(markdownToAdf('==a!adf:textBreak{}b==\n')), [{ content: [text('==a'), text('b==')], type: 'paragraph' }])
const parts = 'unsupported-node-shape: textBreak parts two text nodes CommonMark reads back as one: this one parts something else'
const carried = '!adf:carry{json="{\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}'
for (const markdown of ['!adf:textBreak{}a\n', 'a!adf:textBreak{}\n', 'a!adf:textBreak{}!adf:textBreak{}b\n', '**a**!adf:textBreak{}b\n', '[!adf:textBreak{}](/u)\n', `a!adf:textBreak{}${carried}\n`]) {
assert.equal(content(markdownToAdf(markdown)), parts, markdown)
}
assert.equal(content(markdownToAdf('!adf:status[a!adf:textBreak{}b]\n')), 'unsupported-node-shape: textBreak parts two text nodes: a content slot holds plain text')
assert.equal(content(markdownToAdf('![a!adf:textBreak{}b](/i)\n')), 'unsupported-node-shape: textBreak parts two text nodes: an image description holds plain text')
assert.equal(content(markdownToAdf('a!adf:textBreak{x=y}b\n')), 'unsupported-node-shape: textBreak spells the bare leaf form, !adf:textBreak{}: this one spells more')
assert.deepEqual(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"textBreak\\"}"}\n')), [{ content: [{ type: 'textBreak' }], type: 'paragraph' }])
})
+37 -11
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 { carryFenceType, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { documentAttribute, 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 spelled = block.attributes.get(documentAttribute.key)
if (block.argument === undefined && block.attributes.size === 1 && spelled?.spelling === documentAttribute.value) 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`)
}
@@ -216,22 +231,32 @@ 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
if (blocks === undefined) return success(node)
// A directive builds a content key only from content=empty, which holds no body.
if (node.content !== undefined) 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 +303,9 @@ 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)
const carriedType = carryFenceType(language)
if (carriedType !== undefined) {
const carried = readCarriedBlock(carriedType, text, depth)
if (carried.fault !== undefined) return faulted(carried.fault, path)
reading.carried.add(carried.value)
return success(carried.value)
@@ -6,7 +6,6 @@ import { adfToMarkdown } from '../emit/adf-to-markdown.ts'
import { adfToPlainMarkdown } from '../emit/plain-reduction.ts'
import { largestNesting } from '../../nesting.ts'
import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts'
const code: AdfMark = { type: 'code' }
const em: AdfMark = { type: 'em' }
@@ -18,7 +17,7 @@ const taskTypes = ['blockTaskItem', 'taskItem', 'taskList']
function read(markdown: string): readonly AdfNode[] | string {
const parsed = plainMarkdownToAdf(markdown)
if (!parsed.ok) return parsed.error.code
const blocks = toEditorNormal(parsed.value).content ?? []
const blocks = parsed.value.content ?? []
const pending = [...blocks]
for (let block = pending.pop(); block !== undefined; block = pending.pop()) {
for (const child of block.content ?? []) pending.push(child)
@@ -31,8 +30,8 @@ function read(markdown: string): readonly AdfNode[] | string {
return blocks
}
function normal(...blocks: AdfNode[]): readonly AdfNode[] {
return toEditorNormal(document(...blocks)).content ?? []
function blocks(...content: AdfNode[]): readonly AdfNode[] {
return content
}
function document(...content: AdfNode[]): AdfDocument {
@@ -57,7 +56,7 @@ function bare(type: string, ...content: AdfNode[]): AdfNode {
}
function paragraph(...content: AdfNode[]): AdfNode {
return bare('paragraph', ...content)
return content.length === 0 ? { type: 'paragraph' } : bare('paragraph', ...content)
}
function said(value: string): AdfNode {
@@ -101,7 +100,7 @@ test('reads the rest of an alert marker line as the panel first body paragraph,
assert.deepEqual(read('> [!tip] Title\n> body\n'), [panel('tip', said('Title'), said('body'))])
assert.deepEqual(read('> [!tip] Title\\\n> body\n'), [panel('tip', said('Title'), said('body'))])
assert.deepEqual(read('> [!NOTE]\\\n> Broken.\n'), [panel('info', said('Broken.'))])
assert.deepEqual(read('> [!NOTE]\n'), normal(panel('info', paragraph())))
assert.deepEqual(read('> [!NOTE]\n'), blocks(panel('info', paragraph())))
assert.deepEqual(read('> > [!WARNING]\n> > Inner.\n'), [bare('blockquote', panel('warning', said('Inner.')))])
})
@@ -120,27 +119,27 @@ test('reads a folded callout to an expand titled by the rest of its marker line,
node('expand', { title: 'Why?' }, paragraph(text('See '), text('the docs', link), text(', '), text('now', strong), text('.'))),
])
assert.deepEqual(read('> [!NOTE]- Two\\\n> lines\n'), [node('expand', { title: 'Two' }, said('lines'))])
assert.deepEqual(read('> [!faq]- See [x](http://y)\n'), normal(node('expand', { title: 'See x (http://y)' }, paragraph())))
assert.deepEqual(read('> [!faq]- [a **b**](u "t")[c](u) and [d](v)\n'), normal(node('expand', { title: 'a bc (u) and d (v)' }, paragraph())))
assert.deepEqual(read('> [!faq]- <http://y> or <a@b.c> or <http://a\\b>\n'), normal(node('expand', { title: 'http://y or a@b.c or http://a\\b' }, paragraph())))
assert.deepEqual(read('> [!faq]- [a&#10;b](u)\n'), normal(node('expand', { title: 'a\nb (u)' }, paragraph())))
assert.deepEqual(read('> [!faq]- [!adf:mention[@M]{id=5}](u) !adf:inlineCard{url="http://y"}\n'), normal(node('expand', { title: '@M (u) http://y' }, paragraph())))
assert.deepEqual(read('> [!NOTE]- Set ==x== here\n'), normal(node('expand', { title: 'Set ==x== here' }, paragraph())))
assert.deepEqual(read('> [!faq]- See [x](http://y)\n'), blocks(node('expand', { title: 'See x (http://y)' }, paragraph())))
assert.deepEqual(read('> [!faq]- [a **b**](u "t")[c](u) and [d](v)\n'), blocks(node('expand', { title: 'a bc (u) and d (v)' }, paragraph())))
assert.deepEqual(read('> [!faq]- <http://y> or <a@b.c> or <http://a\\b>\n'), blocks(node('expand', { title: 'http://y or a@b.c or http://a\\b' }, paragraph())))
assert.deepEqual(read('> [!faq]- [a&#10;b](u)\n'), blocks(node('expand', { title: 'a\nb (u)' }, paragraph())))
assert.deepEqual(read('> [!faq]- [!adf:mention[@M]{id=5}](u) !adf:inlineCard{url="http://y"}\n'), blocks(node('expand', { title: '@M (u) http://y' }, paragraph())))
assert.deepEqual(read('> [!NOTE]- Set ==x== here\n'), blocks(node('expand', { title: 'Set ==x== here' }, paragraph())))
assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))])
})
test('reads a folded callout inside an expand to a nested expand', () => {
const markdown = '> [!NOTE]- Outer\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n>\n> > [!TIP]\n> >\n> > > [!NOTE]-\n'
assert.deepEqual(read(markdown), normal(node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph())))))
assert.deepEqual(read('- > [!NOTE]-\n'), normal(bare('bulletList', bare('listItem', bare('expand', paragraph())))))
assert.deepEqual(read('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), normal(bare('expand', node('nestedExpand', { title: 'Inner' }, paragraph()))))
assert.deepEqual(read(markdown), blocks(node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph())))))
assert.deepEqual(read('- > [!NOTE]-\n'), blocks(bare('bulletList', bare('listItem', bare('expand', paragraph())))))
assert.deepEqual(read('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), blocks(bare('expand', node('nestedExpand', { title: 'Inner' }, paragraph()))))
})
test('reads a bullet list whose every item leads with a task marker to a task list', () => {
assert.deepEqual(read('- [x] Write the spec\n- [ ] Ship **it**\n- [X] Tell\n'), [
bare('taskList', task('DONE', text('Write the spec')), task('TODO', text('Ship '), text('it', strong)), task('DONE', text('Tell'))),
])
assert.deepEqual(read('- [x]\n- [ ]\\\n after\n'), normal(bare('taskList', task('DONE'), task('TODO', text('after')))))
assert.deepEqual(read('- [x]\n- [ ]\\\n after\n'), blocks(bare('taskList', task('DONE'), task('TODO', text('after')))))
const minted = plainMarkdownToAdf('- [x] Parent\n - [ ] Child\n')
assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [
node(
@@ -202,7 +201,7 @@ test('reads what the reduction wrote back to the node it reduced, less the attri
assert.deepEqual(roundTripped(node('panel', { localId, panelType }, said('Check.'))), [panel(panelType, said('Check.'))], panelType)
}
const expand = node('expand', { localId, title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))
assert.deepEqual(roundTripped(node('panel', { panelType: 'tip' }, paragraph()), node('expand', { title: 'Empty' }, paragraph())), normal(panel('tip', paragraph()), node('expand', { title: 'Empty' }, paragraph())))
assert.deepEqual(roundTripped(node('panel', { panelType: 'tip' }, paragraph()), node('expand', { title: 'Empty' }, paragraph())), blocks(panel('tip', paragraph()), node('expand', { title: 'Empty' }, paragraph())))
assert.deepEqual(roundTripped(expand), [node('expand', { title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))])
const tasks = bare(
'taskList',
@@ -266,3 +265,8 @@ test('keeps what markdownToAdf reads that no row reads, and refuses only what it
for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}`
assert.equal(typeof read(deep), 'object')
})
test('joins a carried text node to no neighbour, inside a highlight too', () => {
const carried = '!adf:carry{json="{\\"marks\\":[],\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}'
assert.deepEqual(read(`==a${carried}==\n`), blocks(paragraph(text('a', highlight), { marks: [], text: 'b', type: 'text' })))
})
+12
View File
@@ -0,0 +1,12 @@
import type { AdfNode } from '../adf/document.ts'
import { identicalMarks, isPlainText, nodeMarks } 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 isPlainText(previous) && isPlainText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node))
}
+55 -12
View File
@@ -6,7 +6,7 @@
`Bar = 9`
`Next ID = 52`
`Next ID = 59`
| Goal | W |
|---|---|
@@ -24,13 +24,16 @@
| 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 |
| 55 | 0.2.0 | defect | **Spell through the carry every document the emitter refuses today for a carriage return, a NUL, a code-span line start or a newline inside an emoji, mention or status.** | 5 | 5 | 7 | 9 | 1 | 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 |
| 43 | 0.2.0 | decision | **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 |
| 54 | 0.2.0 | | **Make `docs/decisions.md` §The source parts by ADF and format name every import `parse/` takes from `emit/`.** | 2 | 2 | 2 | 5 | 1 | 11.5 |
| 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 |
@@ -41,24 +44,29 @@
| 33 | 0.3.0 | | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 8 | 10.7 |
| 46 | 0.3.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 7, 9 | 10.3 |
| 9 | 0.3.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 6 | 8 | 9 | 10.3 |
| 56 | 0.3.0 | principle | **Give each piece of `blocks.ts`'s block-walk state one owner that returns what it changes.** | 4 | 5 | 2 | 4 | 1 | 6.8 |
| 57 | 0.3.0 | principle | **Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.** | 2 | 3 | 1 | 3 | 7 | 1.2 |
| 58 | 0.3.0 | principle | **Port `ci.sh`, `publish.sh` and `docker-runner.sh` to standalone Python scripts.** | 4 | 6 | 1 | 2 | 7 | -2.2 |
| 8 | 0.4.0 | | **Ship a CLI.** | 3 | 7 | 7 | 6 | 9 | 10.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
documents HTML as it documents markdown, and its tagline and `package.json`'s `description` regain
HTML.
### 55. Spell through the carry every document the emitter refuses today for a carriage return, a NUL, a code-span line start or a newline inside an emoji, mention or status.
`inline-line.ts` refuses a carriage return or NUL in text (`unspellable-character`) and a paragraph
opening with a code-span run (`unspellable-line-start`); `fencedTexts` refuses the same characters
in a code block; `directive-syntax.ts` refuses a newline in an emoji, mention or status
(`unspellable-whitespace`). The inline carry's JSON escapes all of them, so a lossless spelling
exists, and §The code list says a cause the carry answers gets no code. Fixing it revises §Which
code a cause takes and §Markdown in is a canonical fixpoint, which the maintainer decides. Found by
the README-goals audit, 2026-10-03.
### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.
Goal 1 has every call return a result; the boolean guard is the one export that does not, and it
@@ -74,7 +82,8 @@ HTML in input). The set sorts per `docs/decisions.md` §Foreign HTML sorts three
### 43. Give each markdown input its own reader, strict to its own standard.
Today `markdownToAdf` reads CommonMark and the lossless flavour as one input: text shaped like a
directive, a pipe table or a `~~` pair becomes a flavour node where CommonMark reads plain text. A
directive, a pipe table or a `~~` pair becomes a flavour node where CommonMark reads plain text, and
a code fence whose info string opens `adf:` becomes the block carry where CommonMark reads code. A
caller names the markdown it hands in: CommonMark, read as its spec says, or the lossless flavour,
read as `spec/flavour.md` says. Breaking: `MIGRATION.md` says which call a caller takes.
@@ -90,6 +99,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 +111,18 @@ 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.
### 54. Make `docs/decisions.md` §The source parts by ADF and format name every import `parse/` takes from `emit/`.
The entry says `parse/` imports only `commonMarkSpelling` and `openingLinkTakesDirective` from
`emit/`; `parse/markdown-to-adf.ts` also imports `inlineLeaves` from `emit/plain-inline.ts`. Either
the entry names it with its reason, or the reader stops importing it.
### 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
@@ -173,6 +199,23 @@ or report the unminified gzip). The figure lands in README §The package beside
dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6
kB.
### 56. Give each piece of `blocks.ts`'s block-walk state one owner that returns what it changes.
Technical principle "One owner per value": the `Walk` record passes through twelve functions that
mutate it and return `void`; `walk.leaf` alone is written in seven places. `ContainerStack` in the
same file shows the shape to follow.
### 57. Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.
Technical principle "Compose, do not entangle": Deno, Bun and the tests read the install leg's
`node_modules`, the floor and consumer legs the pack leg's, and the browser leg whatever `dist/` the
build leg left, so a leg run alone can pass on stale output.
### 58. Port `ci.sh`, `publish.sh` and `docker-runner.sh` to standalone Python scripts.
Technical principle "Prefer a standalone Python script over a shell script": `AGENTS.md` §3 teaches
bash footguns (`&&` chaining under `||`, `tee /dev/stderr`) the port removes.
### 8. Ship a CLI.
The Goals and G cells are provisional: no README goal or persona covers a CLI yet. The chunk