Compare commits

...

11 Commits

18 changed files with 115 additions and 68 deletions
+2 -2
View File
@@ -9,8 +9,8 @@
`type`: text holding an unescaped `!adf:` and a code fence whose info string opens `adf:` are `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`. 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 - **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 document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}`
`content` key. spells a document holding no `content` key.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc`: two adjacent text nodes a reader would - `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 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` `{attrs=empty}`, `{content=empty}` or `{marks=empty}`, `-0` is spelled `-0`, and a `codeBlock`
+6 -4
View File
@@ -6,7 +6,8 @@ Directives moved under the `!adf:` prefix. `0.2.0` reads `0.1.0`'s spelling with
turning each directive into text and each carried node into an `adf` code block. Before `0.2.0` turning each directive into text and each carried node into an `adf` code block. Before `0.2.0`
reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the
recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by
the tables below. Stored ADF needs no change. the tables below. Stored ADF needs one change: a document `0.1.0` read from empty markdown holds
no `content` key, and gets `content: []`, the empty document it meant.
### Convert markdown ### Convert markdown
@@ -30,8 +31,9 @@ function migrateMarkdown(stored: string) {
- Convert each document once: a second pass can return ok while turning the directives into text. - Convert each document once: a second pass can return ok while turning the directives into text.
Stop `0.1.0` writing first, and record which documents are converted. Stop `0.1.0` writing first, and record which documents are converted.
- A refusal carrying `position` is `0.1.0`'s parse, which refused that markdown before too. One - A refusal carrying `position` is `0.1.0`'s parse, which refused that markdown before too. One
without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF rather than keeping without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF, with
the unconverted markdown. `content: parsed.value.content ?? []` as the recipe gives it, rather than keeping the unconverted
markdown.
### Spellings ### Spellings
@@ -56,7 +58,7 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
| --- | --- | --- | | --- | --- | --- |
| 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 | | 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 | | 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 | | a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | the block carry, refusing a body that is not one node's canonical JSON; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block |
### Error codes ### Error codes
+11 -12
View File
@@ -198,28 +198,27 @@ emit refuses:
| `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span | | `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span |
| `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines | | `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines |
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | | `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the lossless flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body | | `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the lossless flavour spells as CommonMark | write the shape the message names, or remove the reserved directive it names; `spec/flavour.md` lists every type's attributes and body |
## The guarantees ## The guarantees
Serves Goals 1, 3 and 4. Serves Goals 1, 3 and 4.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` — every key and value as `doc` holds it, - `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as JSON, for a document of plain objects
adjacent text nodes, an empty `attrs`, `content` or `marks` and `-0` included, and unknown node as `JSON.parse` builds them — every key and value as `doc` holds it, adjacent text nodes, an
types carried opaquely 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)). ([`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 - 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 `0.2.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below
name every exception. name every exception.
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html` - Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html`
names, with four carve-outs — literal text matching directive, pipe-table or strikethrough names, with four carve-outs — literal text matching directive, pipe-table or strikethrough syntax,
syntax, and a code fence whose info string opens `adf:`, is claimed (escapable — `spec/flavour.md`) and a code fence whose info string opens `adf:`, are claimed (escapable — `spec/flavour.md`) — and
— and one gap: a CommonMark image fits only as one gap: a CommonMark image fits only as its own title-less paragraph; mid-text and titled images
its own title-less paragraph; mid-text and titled images are error results, save an image inside are error results, save an image inside another's description, which flattens into the alt text.
another's description, which flattens into the alt text. Converting back yields the library's Converting back yields the library's canonical spelling, which round-trips byte-identically —
canonical spelling, which round-trips byte-identically — where it converts back at all: a parse where it converts back at all: a parse succeeding is no promise of that, so keep the source until
succeeding is no promise of that, so keep the source until the way back succeeds. the way back succeeds. ``` ` `` ` ``` reads cleanly and then refuses.
``` ` `` ` ``` reads cleanly and then refuses.
- Four CommonMark spellings parse without an error and build a document the reference - Four CommonMark spellings parse without an error and build a document the reference
implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's
empty link, a list continuing past a marker change stays one list against CommonMark's two, a empty link, a list continuing past a marker change stays one list against CommonMark's two, a
+5 -4
View File
@@ -19,7 +19,8 @@ One directory per contract kind:
model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap). model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap).
JSON is two-space indent, keys sorted, and a document read back must deep-equal the fixture's 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 (`docs/decisions.md` §Equality is deep). `spec.json` is the vendored, upstream machine-readable
[spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John suite, byte-exact from [spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json)
MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not (CommonMark 0.31.2, © John MacFarlane,
re-serialized by the corpus gate. [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,8 @@
!adf:codeBlock {attrs=empty}
```js
a
```
```js
b
```
!adf:/codeBlock
@@ -0,0 +1 @@
unsupported-node-shape
+7
View File
@@ -0,0 +1,7 @@
!adf:codeBlock
```
a
```
```
```
!adf:/codeBlock
@@ -82,6 +82,13 @@
}, },
"marks": [], "marks": [],
"type": "codeBlock" "type": "codeBlock"
},
{
"attrs": {
"language": "js"
},
"content": [],
"type": "codeBlock"
} }
], ],
"type": "doc", "type": "doc",
@@ -30,3 +30,6 @@ Next
```js ```js
``` ```
!adf:/codeBlock !adf:/codeBlock
!adf:codeBlock {content=empty language=js}
!adf:/codeBlock
+4 -2
View File
@@ -36,8 +36,10 @@ does not imply a spellable document; `corpus/commonmark-spec/exceptions.json` na
2026-08-24, deep 2026-10-03, the maintainer. Goal 1. Valid while a pipeline or a bot can build a 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. shape the editor would not.
"Equals" is `assert.deepStrictEqual`: every key and value `doc` holds, two adjacent text nodes, an "Equals" is deep equality over the document's JSON values: `assert.deepStrictEqual` on plain
empty `attrs`, `content` or `marks` and `-0` included, with no normalization on either side. objects as `JSON.parse` builds them, since ADF is JSON. Every key and value `doc` holds counts, 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 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 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 reads and writes editor-normal ADF — adjacent text nodes of identical marks and no attributes
+15 -14
View File
@@ -4,10 +4,11 @@ The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` p
CommonMark is a subset apart from raw HTML (below), with four carve-outs: literal text that matches 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 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 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 string opens `adf:` is the opaque carry (wrap it as a bare fence in
image fits only as its own title-less paragraph — mid-text and titled images are named errors. The `!adf:codeBlock {language="adf:…"}` to keep it code) — and one gap: a CommonMark image fits only as
emitted form is contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this its own title-less paragraph — mid-text and titled images are named errors. The emitted form is
grammar in the sections below. contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this grammar in the
sections below.
## Canonical form ## Canonical form
@@ -446,8 +447,8 @@ Attributes and the carry fallback read as in the block sections, the carry in it
the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot as 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 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 is a named 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, error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the slot.
`!adf:text` included; content on a node that takes none is a named error. 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). - `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
- `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` - `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text`
@@ -527,14 +528,14 @@ that depth: `attrs: {}` differs from no `attrs`, and a directive spells it `{att
breaks at every node the emitter carries, so no emitted carry sits inside a mark spelling. 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 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 spelling does not list, a value that is not the spelling's type, an attribute the spelling needs and
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 the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside
text node or over text holding a newline, or a spelling CommonMark's flanking rules cannot open or another mark, `code` over anything but a text node or over text holding a newline, or a spelling
close where the run sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere — the CommonMark's flanking rules cannot open or close where the run sits (`un**-real**istic`), or one
intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the CommonMark's matching pairs elsewhere — the intra-word `*` runs together with a neighbouring `**`,
merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a and the multiple-of-3 rule can leave the merged run's pairing to another delimiter — rides the
mark spelling is a named error in input: the carry restores its node exactly, marks included inline carry whole. An opaque carry inside a mark spelling is a named error in input: the carry
(`docs/decisions.md` §Unknown nodes ride the carry). restores its node exactly, marks included (`docs/decisions.md` §Unknown nodes ride the carry).
``` ```
!adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed]. !adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed].
+1 -1
View File
@@ -17,7 +17,7 @@ const argumentByType = new Map(
export const documentName = 'doc' export const documentName = 'doc'
// spec/flavour.md, Directives: a document holding no content key, which the empty string cannot spell. // 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 documentAttribute = { key: 'content', value: 'none' }
export const documentSpelling = spellDirectiveOpener(documentName, undefined, spellAttributes([[documentAttribute.key, documentAttribute.value]])) export const documentSpelling = spellDirectiveOpener(documentName, undefined, spellAttributes([[documentAttribute.key, documentAttribute.value]]))
+2 -2
View File
@@ -39,7 +39,7 @@ export function carryFenceType(info: string): string | undefined {
export function readCarriedBlock(type: string, body: string, depth: number): Read<AdfNode> { export function readCarriedBlock(type: string, body: string, depth: number): Read<AdfNode> {
if (type !== '' && !infoStringCarries(type)) { 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`) } return { fault: unsupportedNodeShape(`the carry fence names a type no info string carries back: spell it ${carryFencePrefix} with the type in the JSON; ${fenceEscape(type)}`) }
} }
const read = readCarriedJson(body, 'two-space', largestNesting - depth, type) const read = readCarriedJson(body, 'two-space', largestNesting - depth, type)
if (read.fault !== undefined || type !== '' || !infoStringCarries(read.value.type)) return read if (read.fault !== undefined || type !== '' || !infoStringCarries(read.value.type)) return read
@@ -89,7 +89,7 @@ function fenceEscape(type: string): string {
function typedValue(value: JsonValue, type: string): Read<JsonValue> { function typedValue(value: JsonValue, type: string): Read<JsonValue> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { value } 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`) } if ('type' in value) return { fault: unsupportedNodeShape(`the ${carryFencePrefix}${type} fence names its node's type: this JSON holds a type as well; ${fenceEscape(type)}`) }
return { value: { ...value, type } } return { value: { ...value, type } }
} }
+1 -1
View File
@@ -313,7 +313,7 @@ function partText(items: readonly Inline[], scan: Scan): Result<AdfNode[]> {
const previous = items[index - 1] const previous = items[index - 1]
const next = items[index + 1] const next = items[index + 1]
if (previous === undefined || next === undefined || !isNode(previous) || !isNode(next) || !readsAsOne(previous, next)) { 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 failure('unsupported-node-shape', `delete ${textBreakSpelling} here: it stands only between two runs of text with the same formatting, which would otherwise read as one`, scan.path)
} }
} }
return success(parted) return success(parted)
+19 -5
View File
@@ -92,9 +92,12 @@ test('builds an empty document from input holding no block, and one holding no c
const form = 'unsupported-node-shape: doc spells the one form !adf:doc {content=none}: this one spells another' 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\n')), form)
assert.equal(content(markdownToAdf('!adf:doc x {content=none}\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=empty}\n')),
'unsupported-node-shape: an empty document is empty markdown, and !adf:doc {content=none} spells a document holding no content key: this one spells content=empty',
)
assert.equal(content(markdownToAdf('!adf:doc {content="none"}\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' const alone = 'unsupported-node-shape: delete the !adf:doc {content=none} line to give the document content: it stands only as the whole document'
assert.equal(content(markdownToAdf('x\n\n!adf:doc {content=none}\n')), alone) 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: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('!adf:doc\n!adf:/doc\n')), 'malformed-directive: doc takes no body, so no !adf:/doc closes it; \\!adf: keeps the prefix literal')
@@ -371,11 +374,11 @@ test('reads the carry fence back to the node its info string names and its JSON
assert.deepEqual(content(markdownToAdf('```adf:\n{\n "type": "a`b"\n}\n```\n')), [{ type: 'a`b' }]) assert.deepEqual(content(markdownToAdf('```adf:\n{\n "type": "a`b"\n}\n```\n')), [{ type: 'a`b' }])
assert.equal( assert.equal(
content(markdownToAdf('```adf:blockCard\n{\n "type": "blockCard"\n}\n```\n')), 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", 'unsupported-node-shape: the adf:blockCard fence names its node\'s type: this JSON holds a type as well; !adf:codeBlock {language="adf:blockCard"} around a bare fence keeps it a code block',
) )
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 "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") 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' 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; !adf:codeBlock {language="adf:\\\\"} around a bare fence keeps it a code block'
assert.equal(content(markdownToAdf('```adf:\\\\\n{}\n```\n')), unnamed) assert.equal(content(markdownToAdf('```adf:\\\\\n{}\n```\n')), unnamed)
}) })
@@ -1087,7 +1090,7 @@ test('names the directive mark left without the content it wraps', () => {
test('reads the text break only between two text nodes CommonMark joins, building no node', () => { 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' }])
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 parts = 'unsupported-node-shape: delete !adf:textBreak{} here: it stands only between two runs of text with the same formatting, which would otherwise read as one'
const carried = '!adf:carry{json="{\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}' 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`]) { 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(markdown)), parts, markdown)
@@ -1097,3 +1100,14 @@ test('reads the text break only between two text nodes CommonMark joins, buildin
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.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' }]) assert.deepEqual(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"textBreak\\"}"}\n')), [{ content: [{ type: 'textBreak' }], type: 'paragraph' }])
}) })
test('leads a refusal ordinary editing meets with the edit that fixes it', () => {
assert.equal(
content(markdownToAdf('!adf:paragraph {content=empty}\nText.\n!adf:/paragraph\n')),
'unsupported-node-shape: remove content=empty to give the paragraph a body: content=empty holds none, and this one holds one',
)
assert.equal(
content(markdownToAdf('!adf:codeBlock\n```\na\n```\n```\n```\n!adf:/codeBlock\n')),
'unsupported-node-shape: delete the empty fence: a fence beside another holds code, and this one holds none',
)
})
+9 -4
View File
@@ -8,16 +8,17 @@ import type { LinkDefinitions } from './inline-content.ts'
import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts' import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts' import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { documentAttribute, documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts' import { documentAttribute, documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts'
import { emptyKeys, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.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 { inlineLeaves } from '../emit/plain-inline.ts'
import { languageSlot } from '../code-language.ts' import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain-conventions.ts' import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain-conventions.ts'
import { mintTaskIds } from './task-ids.ts' import { mintTaskIds } from './task-ids.ts'
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts' import { parseInlineContent } from './inline-content.ts'
import { readBlockDirectiveNode } from './directive-nodes.ts' import { readBlockDirectiveNode } from './directive-nodes.ts'
import { spellsEmpty } from '../empty-keys.ts'
import { unsupportedNodeShape } from '../directive-syntax.ts' import { unsupportedNodeShape } from '../directive-syntax.ts'
type Paragraph = Extract<Block, { kind: 'paragraph' }> type Paragraph = Extract<Block, { kind: 'paragraph' }>
@@ -58,7 +59,7 @@ function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErr
for (const [index, block] of blocks.entries()) { for (const [index, block] of blocks.entries()) {
const nodePath = [...path, 'content', content.length] const nodePath = [...path, 'content', content.length]
if (block.kind === 'directive' && block.name === documentName) { 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`) const fault = documentFault(block) ?? unsupportedNodeShape(`delete the ${documentSpelling} line to give the document content: it stands only as the whole document`)
return positioned(faulted(fault, nodePath), block.position) return positioned(faulted(fault, nodePath), block.position)
} }
if (block.kind === 'directive' && block.name === listBreakName) { if (block.kind === 'directive' && block.name === listBreakName) {
@@ -85,6 +86,9 @@ function listBreakFault(block: DirectiveBlock, previous: Block | undefined, next
function documentFault(block: DirectiveBlock): ConvertFault | undefined { function documentFault(block: DirectiveBlock): ConvertFault | undefined {
const spelled = block.attributes.get(documentAttribute.key) const spelled = block.attributes.get(documentAttribute.key)
if (block.argument === undefined && block.attributes.size === 1 && spelled?.spelling === documentAttribute.value) return undefined if (block.argument === undefined && block.attributes.size === 1 && spelled?.spelling === documentAttribute.value) return undefined
if (block.argument === undefined && block.attributes.size === 1 && spellsEmpty(spelled)) {
return unsupportedNodeShape(`an empty document is empty markdown, and ${documentSpelling} spells a document holding no content key: this one spells ${documentAttribute.key}=empty`)
}
return unsupportedNodeShape(`${documentName} spells the one form ${documentSpelling}: this one spells another`) return unsupportedNodeShape(`${documentName} spells the one form ${documentSpelling}: this one spells another`)
} }
@@ -232,7 +236,7 @@ function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, re
const { contentModel, node } = read const { contentModel, node } = read
if (blocks === undefined) return success(node) if (blocks === undefined) return success(node)
// A directive builds a content key only from content=empty, which holds no body. // 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 (node.content !== undefined) return blocks.length === 0 ? success(node) : failure('unsupported-node-shape', `remove content=empty to give the ${node.type} a body: content=empty holds none, and this one holds one`, path)
if (contentModel === 'code') return codeDirectiveNode(node, blocks, path) if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path) if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path)
return containerNode(node, blocks, reading, path, depth) return containerNode(node, blocks, reading, path, depth)
@@ -248,13 +252,14 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver
const [first] = fences 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 (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.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) if (fences.length > 1 && fences.some((fence) => fence.text === '')) return failure('unsupported-node-shape', `delete the empty fence: a fence beside another holds code, and this one holds none`, path)
const attribute = nodeAttrs(node)['language'] const attribute = nodeAttrs(node)['language']
const fromFence = first.language !== '' const fromFence = first.language !== ''
const slot = languageSlot(fromFence ? first.language : attribute) const slot = languageSlot(fromFence ? first.language : attribute)
if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) { 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) 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)
} }
if (fromFence && emptyKeys(node).includes('attrs')) return failure('unsupported-node-shape', `${node.type} spells attrs=empty beside an attribute it holds`, path)
const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: first.language } } : node 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' })))) return success(withContent(spelled, first.text === '' ? [] : fences.map((fence): AdfNode => ({ text: fence.text, type: 'text' }))))
} }
@@ -30,10 +30,6 @@ function read(markdown: string): readonly AdfNode[] | string {
return blocks return blocks
} }
function blocks(...content: AdfNode[]): readonly AdfNode[] {
return content
}
function document(...content: AdfNode[]): AdfDocument { function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 } return { content, type: 'doc', version: 1 }
} }
@@ -100,7 +96,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('> [!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> Broken.\n'), [panel('info', said('Broken.'))])
assert.deepEqual(read('> [!NOTE]\n'), blocks(panel('info', paragraph()))) assert.deepEqual(read('> [!NOTE]\n'), [panel('info', paragraph())])
assert.deepEqual(read('> > [!WARNING]\n> > Inner.\n'), [bare('blockquote', panel('warning', said('Inner.')))]) assert.deepEqual(read('> > [!WARNING]\n> > Inner.\n'), [bare('blockquote', panel('warning', said('Inner.')))])
}) })
@@ -119,27 +115,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('.'))), 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('> [!NOTE]- Two\\\n> lines\n'), [node('expand', { title: 'Two' }, said('lines'))])
assert.deepEqual(read('> [!faq]- See [x](http://y)\n'), blocks(node('expand', { title: 'See x (http://y)' }, paragraph()))) assert.deepEqual(read('> [!faq]- See [x](http://y)\n'), [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]- [a **b**](u "t")[c](u) and [d](v)\n'), [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]- <http://y> or <a@b.c> or <http://a\\b>\n'), [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]- [a&#10;b](u)\n'), [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('> [!faq]- [!adf:mention[@M]{id=5}](u) !adf:inlineCard{url="http://y"}\n'), [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]- Set ==x== here\n'), [node('expand', { title: 'Set ==x== here' }, paragraph())])
assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))]) assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))])
}) })
test('reads a folded callout inside an expand to a nested expand', () => { 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' const markdown = '> [!NOTE]- Outer\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n>\n> > [!TIP]\n> >\n> > > [!NOTE]-\n'
assert.deepEqual(read(markdown), blocks(node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph()))))) assert.deepEqual(read(markdown), [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('- > [!NOTE]-\n'), [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())))) assert.deepEqual(read('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), [bare('expand', node('nestedExpand', { title: 'Inner' }, paragraph()))])
}) })
test('reads a bullet list whose every item leads with a task marker to a task list', () => { 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'), [ 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'))), 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'), blocks(bare('taskList', task('DONE'), task('TODO', text('after'))))) assert.deepEqual(read('- [x]\n- [ ]\\\n after\n'), [bare('taskList', task('DONE'), task('TODO', text('after')))])
const minted = plainMarkdownToAdf('- [x] Parent\n - [ ] Child\n') const minted = plainMarkdownToAdf('- [x] Parent\n - [ ] Child\n')
assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [ assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [
node( node(
@@ -201,7 +197,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) 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.'))) 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())), blocks(panel('tip', paragraph()), node('expand', { title: 'Empty' }, paragraph()))) assert.deepEqual(roundTripped(node('panel', { panelType: 'tip' }, paragraph()), node('expand', { title: 'Empty' }, paragraph())), [panel('tip', paragraph()), node('expand', { title: 'Empty' }, paragraph())])
assert.deepEqual(roundTripped(expand), [node('expand', { title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))]) assert.deepEqual(roundTripped(expand), [node('expand', { title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))])
const tasks = bare( const tasks = bare(
'taskList', 'taskList',
@@ -268,5 +264,5 @@ test('keeps what markdownToAdf reads that no row reads, and refuses only what it
test('joins a carried text node to no neighbour, inside a highlight too', () => { test('joins a carried text node to no neighbour, inside a highlight too', () => {
const carried = '!adf:carry{json="{\\"marks\\":[],\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}' 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' }))) assert.deepEqual(read(`==a${carried}==\n`), [paragraph(text('a', highlight), { marks: [], text: 'b', type: 'text' })])
}) })