Compare commits
11 Commits
423dcbbff5
...
79d9b4db53
| Author | SHA1 | Date | |
|---|---|---|---|
| 79d9b4db53 | |||
| 912c5039aa | |||
| fb78063f12 | |||
| 5cffe82dd5 | |||
| d9f2d325ca | |||
| 419bbcb4f2 | |||
| 4d7e8c944c | |||
| ff9bb7fab2 | |||
| 5a7279059d | |||
| 0ac5dccf11 | |||
| 6fc0d6bba9 |
+2
-2
@@ -9,8 +9,8 @@
|
||||
`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.
|
||||
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`
|
||||
|
||||
+6
-4
@@ -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`
|
||||
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
|
||||
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
|
||||
|
||||
@@ -30,8 +31,9 @@ function migrateMarkdown(stored: string) {
|
||||
- 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.
|
||||
- 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
|
||||
the unconverted markdown.
|
||||
without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF, with
|
||||
`content: parsed.value.content ?? []` as the recipe gives it, rather than keeping the unconverted
|
||||
markdown.
|
||||
|
||||
### 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 |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -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-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-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
|
||||
|
||||
Serves Goals 1, 3 and 4.
|
||||
|
||||
- `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
|
||||
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as JSON, for a document of plain objects
|
||||
as `JSON.parse` builds them — 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 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
|
||||
succeeding is no promise of that, so keep the source until the way back succeeds.
|
||||
``` ` `` ` ``` reads cleanly and then refuses.
|
||||
names, with four carve-outs — literal text matching directive, pipe-table or strikethrough syntax,
|
||||
and a code fence whose info string opens `adf:`, are 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 succeeding is no promise of that, so keep the source until
|
||||
the way back succeeds. ``` ` `` ` ``` reads cleanly and then refuses.
|
||||
- Four CommonMark spellings parse without an error and build a document the reference
|
||||
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
|
||||
|
||||
+5
-4
@@ -19,7 +19,8 @@ One directory per contract kind:
|
||||
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
|
||||
(`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.
|
||||
(`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,8 @@
|
||||
!adf:codeBlock {attrs=empty}
|
||||
```js
|
||||
a
|
||||
```
|
||||
```js
|
||||
b
|
||||
```
|
||||
!adf:/codeBlock
|
||||
@@ -0,0 +1 @@
|
||||
unsupported-node-shape
|
||||
@@ -0,0 +1,7 @@
|
||||
!adf:codeBlock
|
||||
```
|
||||
a
|
||||
```
|
||||
```
|
||||
```
|
||||
!adf:/codeBlock
|
||||
@@ -82,6 +82,13 @@
|
||||
},
|
||||
"marks": [],
|
||||
"type": "codeBlock"
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"language": "js"
|
||||
},
|
||||
"content": [],
|
||||
"type": "codeBlock"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
|
||||
@@ -30,3 +30,6 @@ Next
|
||||
```js
|
||||
```
|
||||
!adf:/codeBlock
|
||||
|
||||
!adf:codeBlock {content=empty language=js}
|
||||
!adf:/codeBlock
|
||||
|
||||
+4
-2
@@ -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
|
||||
shape the editor would not.
|
||||
|
||||
"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.
|
||||
"Equals" is deep equality over the document's JSON values: `assert.deepStrictEqual` on plain
|
||||
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
|
||||
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
|
||||
|
||||
+15
-14
@@ -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
|
||||
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.
|
||||
string opens `adf:` is the opaque carry (wrap it as a bare fence in
|
||||
`!adf:codeBlock {language="adf:…"}` 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.
|
||||
|
||||
## 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
|
||||
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
|
||||
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.
|
||||
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).
|
||||
- `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.
|
||||
|
||||
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 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
|
||||
merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a
|
||||
mark spelling is a named error in input: the carry restores its node exactly, marks included
|
||||
(`docs/decisions.md` §Unknown nodes ride the carry).
|
||||
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs 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 merged run's pairing to another delimiter — rides the
|
||||
inline carry whole. An opaque carry inside a mark spelling is a named error in input: 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].
|
||||
|
||||
@@ -17,7 +17,7 @@ const argumentByType = new Map(
|
||||
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 documentAttribute = { key: 'content', value: 'none' }
|
||||
|
||||
export const documentSpelling = spellDirectiveOpener(documentName, undefined, spellAttributes([[documentAttribute.key, documentAttribute.value]]))
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export function carryFenceType(info: string): string | 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`) }
|
||||
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)
|
||||
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> {
|
||||
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 } }
|
||||
}
|
||||
|
||||
|
||||
@@ -313,7 +313,7 @@ function partText(items: readonly Inline[], scan: Scan): Result<AdfNode[]> {
|
||||
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 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)
|
||||
|
||||
@@ -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'
|
||||
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=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)
|
||||
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('!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')
|
||||
@@ -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.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",
|
||||
'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```\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)
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
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\\"}"}'
|
||||
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)
|
||||
@@ -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.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',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -8,16 +8,17 @@ import type { LinkDefinitions } from './inline-content.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 { emptyKeys, nodeAttrs, nodeContent } from '../../adf/document.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 { mintTaskIds } from './task-ids.ts'
|
||||
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
import { parseInlineContent } from './inline-content.ts'
|
||||
import { readBlockDirectiveNode } from './directive-nodes.ts'
|
||||
import { spellsEmpty } from '../empty-keys.ts'
|
||||
import { unsupportedNodeShape } from '../directive-syntax.ts'
|
||||
|
||||
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()) {
|
||||
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`)
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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 && 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`)
|
||||
}
|
||||
|
||||
@@ -232,7 +236,7 @@ function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, re
|
||||
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 (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 === 'inline') return inlineBodyNode(node, blocks, reading, path)
|
||||
return containerNode(node, blocks, reading, path, depth)
|
||||
@@ -248,13 +252,14 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver
|
||||
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)
|
||||
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 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)
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
function blocks(...content: AdfNode[]): readonly AdfNode[] {
|
||||
return content
|
||||
}
|
||||
|
||||
function document(...content: AdfNode[]): AdfDocument {
|
||||
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('> [!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.')))])
|
||||
})
|
||||
|
||||
@@ -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('.'))),
|
||||
])
|
||||
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]- [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 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('> [!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'), [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'), [node('expand', { title: 'http://y or a@b.c or http://a\\b' }, paragraph())])
|
||||
assert.deepEqual(read('> [!faq]- [a 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'), [node('expand', { title: '@M (u) http://y' }, 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.'))])
|
||||
})
|
||||
|
||||
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), 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()))))
|
||||
assert.deepEqual(read(markdown), [node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', 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'), [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'), 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')
|
||||
assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [
|
||||
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)
|
||||
}
|
||||
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.')))])
|
||||
const tasks = bare(
|
||||
'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', () => {
|
||||
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' })])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user