Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 423dcbbff5 | |||
| b9760d0af2 | |||
| d7ba8e6aae | |||
| bba2fa409e | |||
| c3bc2eb572 | |||
| 2700184e56 | |||
| d9c3e4b349 | |||
| 2d02165702 | |||
| a565d49c24 | |||
| ade3997cfd | |||
| 31576bb824 | |||
| 723c0e31ef | |||
| a5238e4fe5 | |||
| 9f0f520afd | |||
| 45f1bee94b | |||
| 20a191e7e9 | |||
| 3da45a9da7 | |||
| 0bc19238b4 | |||
| 1458b09449 | |||
| eab28ae8ff | |||
| afb53ca594 | |||
| 70e2639982 | |||
| fdbf43398c | |||
| 0c507f981f | |||
| 1d0ae695da | |||
| f180943edf | |||
| 9ab7286e6d | |||
| bfc4b2ce00 |
@@ -10,8 +10,14 @@ In `docs/decisions.md`:
|
|||||||
- Plain markdown is a flavour of the grammar
|
- Plain markdown is a flavour of the grammar
|
||||||
- The round-trip is the product
|
- The round-trip is the product
|
||||||
- Markdown in is a canonical fixpoint
|
- 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
|
- 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
|
- Foreign HTML sorts three ways
|
||||||
- Names stay text
|
- Names stay text
|
||||||
- Directives under `!adf:`
|
- 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.
|
keeps collecting instances is wrong: rewrite it.
|
||||||
|
|
||||||
Which output the audience expects — README goal 5 — is settled by a reader panel rather than
|
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
|
`## 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
|
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.
|
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`.
|
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
|
### Stated numbers
|
||||||
|
|
||||||
A stated number — 500 levels, the branch floor — is kept; a chunk that cannot keep it asks, naming
|
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
|
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
|
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.
|
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
@@ -2,10 +2,20 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
- **Breaking:** directives, the opaque carry among them (now `carry`), are spelled under an `!adf:`
|
- **Breaking:** directives, the inline opaque carry among them (now `!adf:carry{json="…"}`), are
|
||||||
prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`, `!adf:name arg {attrs}`) in place
|
spelled under an `!adf:` prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`,
|
||||||
of the `:::`/`::`/`:name` forms: text holding an unescaped `!adf:` is claimed, and `adf` is an
|
`!adf:name arg {attrs}`) in place of the `:::`/`::`/`:name` forms, and the block carry is a code
|
||||||
ordinary code block language. Convert stored markdown per `MIGRATION.md`.
|
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
|
- **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no
|
||||||
CommonMark escape spells is written as `!adf:link[text]{attrs}`.
|
CommonMark escape spells is written as `!adf:link[text]{attrs}`.
|
||||||
- **Breaking:** some directive refusals carry `malformed-directive` where they carried
|
- **Breaking:** some directive refusals carry `malformed-directive` where they carried
|
||||||
|
|||||||
+7
-3
@@ -22,7 +22,8 @@ import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1'
|
|||||||
|
|
||||||
function migrateMarkdown(stored: string) {
|
function migrateMarkdown(stored: string) {
|
||||||
const parsed = markdownToAdf010(stored)
|
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}` |
|
| `::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` |
|
| `::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}` |
|
| `: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 |
|
| `\:` keeps a directive literal | `\!adf:` keeps a directive literal |
|
||||||
| `:adf{json="…"}` carrying a link for its `collection`, `id` or `occurrenceKey` | `!adf:link[text]{attrs}` |
|
| `: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
|
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
|
### 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` |
|
| 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 |
|
| 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
|
### Error codes
|
||||||
|
|
||||||
@@ -63,6 +66,7 @@ it named converts.
|
|||||||
| Input | `0.1.0` | `0.2.0` |
|
| 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 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 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` |
|
| 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` |
|
| an empty node the `::taskItem` spelling row names, written as a leaf | parses | `malformed-directive` |
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ which is free text.
|
|||||||
- **LLM/agent pipeline** — hands documents to a model as markdown and writes the edits back.
|
- **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.
|
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
|
## The shape
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -171,7 +175,7 @@ Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.2.0`
|
|||||||
|
|
||||||
| Code | Fires when | What you can do |
|
| 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 |
|
| `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 |
|
| `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 |
|
| `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.
|
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)).
|
([`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 three carve-outs — literal text matching directive, pipe-table or strikethrough
|
names, with four 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
|
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
|
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
|
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
|
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.
|
makes a call loop forever.
|
||||||
- The emitted formats are semver surface
|
- 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)).
|
([`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
|
`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
|
through as well, and a construct outside it is an error; well-formed HTML only — no tag-soup
|
||||||
recovery.
|
recovery.
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
try {
|
try {
|
||||||
const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js')
|
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) => ({
|
window.convertCorpus = (corpus) => ({
|
||||||
errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
|
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 }) => {
|
realPayloads: corpus.realPayloads.map(({ json, name }) => {
|
||||||
const adf = JSON.parse(json)
|
const adf = JSON.parse(json)
|
||||||
const emitted = adfToMarkdown(adf)
|
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 }) => {
|
roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => {
|
||||||
const adf = JSON.parse(json)
|
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) {
|
} catch (cause) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict'
|
|||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
import { extname, join } from 'node:path'
|
import { extname, join } from 'node:path'
|
||||||
import { readFileSync, readdirSync } from 'node:fs'
|
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 contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' }
|
||||||
const driver = 'http://127.0.0.1:4444'
|
const driver = 'http://127.0.0.1:4444'
|
||||||
@@ -50,6 +49,11 @@ function fixtureNames(kind, extension) {
|
|||||||
.sort()
|
.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) {
|
function refusal(result) {
|
||||||
return result.ok ? '' : `${result.error.code}: ${result.error.message}`
|
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.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
|
||||||
assert.equal(result.emitted.value, markdown)
|
assert.equal(result.emitted.value, markdown)
|
||||||
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
|
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]
|
const result = results.normalization[index]
|
||||||
checking(name, result, () => {
|
checking(name, result, () => {
|
||||||
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
|
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.isDocument, `${name}.json is no ADF document`)
|
||||||
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
|
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.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
@@ -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
|
`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).
|
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
|
JSON is two-space indent, keys sorted, and a document read back must deep-equal the fixture's
|
||||||
sorted. `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 suite, byte-exact from
|
||||||
[spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John
|
[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
|
MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not
|
||||||
re-serialized by the corpus gate.
|
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:\\
|
||||||
|
{}
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
```adf:blockCard
|
||||||
|
{
|
||||||
|
"type": "blockCard"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
```carry
|
```adf:blockCard
|
||||||
{"type":
|
{"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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
!adf:paragraph {content=empty}
|
||||||
|
Text.
|
||||||
|
!adf:/paragraph
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
!adf:paragraph {attrs=none}
|
||||||
|
Text.
|
||||||
|
!adf:/paragraph
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
**!adf:date{marks=empty}**
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
a!adf:textBreak{x=y}b
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
a!adf:textBreak[x]b
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
**a**!adf:textBreak{}b
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
!adf:textBreak{}Hello
|
||||||
@@ -14,7 +14,19 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"language": "carry"
|
"language": "adf:blockCard"
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "{\n \"attrs\": {}\n}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "codeBlock"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"language": "adf:"
|
||||||
},
|
},
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
!adf:codeBlock {language=carry}
|
```carry
|
||||||
```
|
|
||||||
{
|
{
|
||||||
"type": "blockCard"
|
"type": "blockCard"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
!adf:codeBlock {language="adf:blockCard"}
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"attrs": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
!adf:/codeBlock
|
!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": {
|
"attrs": {
|
||||||
"rounded": true
|
"rounded": true
|
||||||
@@ -13,12 +13,11 @@
|
|||||||
],
|
],
|
||||||
"type": "paragraph"
|
"type": "paragraph"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"type": "panel"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```carry
|
```adf:panel
|
||||||
{
|
{
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"panelType": "extra info"
|
"panelType": "extra info"
|
||||||
@@ -33,8 +32,7 @@
|
|||||||
],
|
],
|
||||||
"type": "paragraph"
|
"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",
|
"type": "doc",
|
||||||
"version": 1
|
"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": {
|
> "attrs": {
|
||||||
> "url": "https://example.com/quoted"
|
> "url": "https://example.com/quoted"
|
||||||
> },
|
> }
|
||||||
> "type": "blockCard"
|
|
||||||
> }
|
> }
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
- ```carry
|
- ```adf:blockCard
|
||||||
{
|
{
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"url": "https://example.com/listed"
|
"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": {
|
"attrs": {
|
||||||
"url": "https://example.com/roadmap"
|
"url": "https://example.com/roadmap"
|
||||||
},
|
}
|
||||||
"type": "blockCard"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
!adf:panel info
|
!adf:panel info
|
||||||
The card below has no spelling yet.
|
The card below has no spelling yet.
|
||||||
|
|
||||||
```carry
|
```adf:embedCard
|
||||||
{
|
{
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"layout": "wide",
|
"layout": "wide",
|
||||||
"url": "https://example.com/board",
|
"url": "https://example.com/board",
|
||||||
"width": 100
|
"width": 100
|
||||||
},
|
}
|
||||||
"type": "embedCard"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
!adf:/panel
|
!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
@@ -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
|
2026-08-23, real payloads 2026-09-15, the maintainer. Goal 1. Valid while a consumer saves back
|
||||||
through the lossless pair.
|
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.
|
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
|
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
|
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
|
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.
|
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
|
2026-08-24, deep 2026-10-03, the maintainer. Goal 1. Valid while a pipeline or a bot can build a
|
||||||
merges.
|
shape the editor would not.
|
||||||
|
|
||||||
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
|
"Equals" is `assert.deepStrictEqual`: every key and value `doc` holds, two adjacent text nodes, an
|
||||||
and no attributes merged, JSON number semantics, an empty attrs object, marks array or content
|
empty `attrs`, `content` or `marks` and `-0` included, with no normalization on either side.
|
||||||
array the absent key — the only domain markdown can restore.
|
CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader
|
||||||
`todo.md` item 40 replaces this with deep equality (2026-09-28, the maintainer).
|
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
|
## Unknown nodes ride the carry
|
||||||
|
|
||||||
2026-08-23, extended to misplaced known nodes 2026-08-26, the maintainer. Goal 1. Valid while ADF
|
2026-08-23, extended to misplaced known nodes 2026-08-26 and to code block children 2026-10-03, the
|
||||||
holds nodes, or node positions, this library does not spell.
|
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
|
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
|
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
|
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`.
|
checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`. A
|
||||||
Where a container's own spelling cannot hold the child it has — a `bulletList` holding other than
|
`codeBlock` holding a child no fence holds — anything but a text node carrying no marks, `attrs` or
|
||||||
`listItem`, a `codeBlock` other than text — the error result names that instead.
|
`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
|
## 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.
|
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
|
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
|
## 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
|
- 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
|
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
|
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
|
that code, and the names the flavour reserves part on form: a form the grammar does not have is
|
||||||
claim code — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place
|
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
|
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
|
- 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
|
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
|
its content model does not take — is `unsupported-node-shape`, the emitter's code for the same
|
||||||
|
|||||||
+70
-39
@@ -1,9 +1,10 @@
|
|||||||
# The markdown flavour
|
# The markdown flavour
|
||||||
|
|
||||||
The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain
|
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
|
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, and 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 one gap: a CommonMark
|
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
|
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
|
emitted form is contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this
|
||||||
grammar in the sections below.
|
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
|
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.
|
carries an attribute, it is the inline directive.
|
||||||
- An empty paragraph — real payloads carry them — is an `!adf:paragraph` … `!adf:/paragraph` pair
|
- 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
|
- 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
|
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
|
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.
|
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 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
|
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
|
## 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`
|
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
|
§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,
|
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,
|
naming the spelling it takes. Four reserved names read back to no node: `carry` for the inline
|
||||||
as both directive name and fence info string, and `listBreak` for the leaf that parts two adjacent
|
opaque carry, `listBreak` for the leaf that parts two adjacent lists and `doc` for a document
|
||||||
lists (Canonical form).
|
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`
|
**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
|
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
|
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
|
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.
|
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
|
**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
|
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
|
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:
|
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 —
|
- **Block position**: a fenced code block with info string `adf:` and the node's type, body = the
|
||||||
two-space indent, object keys sorted.
|
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),
|
- **Inline position**: `!adf:carry{json="…"}` — compact serialization (keys sorted, no whitespace),
|
||||||
JSON-string-escaped into the attribute.
|
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
|
the attribute the section below keeps for a language no info string holds, so the reservation
|
||||||
stays absolute.
|
stays absolute. In block-directive position `!adf:carry` is a named error — the carry's block form
|
||||||
In block-directive position `!adf:carry` is a named error — the carry's block form is the fence.
|
is the fence.
|
||||||
|
|
||||||
## Raw HTML in input
|
## 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
|
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
|
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),
|
(`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
|
quoted, `-0` spelled `-0`. `markdownToAdf` builds an `attrs`, `content` or `marks` key only where the
|
||||||
ADF reads an empty attrs object, marks array or content array as the absent key (`docs/decisions.md`
|
markdown spells one, an empty one through its reserved key (Attributes), so a document reads back
|
||||||
§Equality is editor-normal) — the grammar's empty-`{attrs}` omission already collapses the two
|
deep-equal (`docs/decisions.md` §Equality is deep). A node CommonMark spells takes the directive
|
||||||
spellings.
|
form to hold an empty key.
|
||||||
|
|
||||||
Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a
|
Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a
|
||||||
`json` value: `!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
|
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.
|
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.
|
form.
|
||||||
|
|
||||||
- `blockquote`, `bulletList`, `listItem` — containers, block body. Attributes: `localId` (string).
|
- `blockquote`, `bulletList`, `listItem` — containers, block body. Attributes: `localId` (string).
|
||||||
- `codeBlock` — container, body one fenced code block whose info string is the language and whose
|
- `codeBlock` — container, body one fenced code block per text node, each fence's info string the
|
||||||
content is the node's. Attributes: `hideLineNumbers` (boolean), `language` (string), `localId`
|
language and its content the node's text; a node holding no `content` key is one empty fence.
|
||||||
(string), `uniqueId` (string), `wrap` (boolean). A language no info string carries back — empty,
|
Attributes: `hideLineNumbers` (boolean), `language` (string), `localId` (string), `uniqueId`
|
||||||
the reserved `carry`, or holding a backtick, a backslash, a control character, edge whitespace or
|
(string), `wrap` (boolean). A language no info string carries back — empty, opening the reserved
|
||||||
an entity reference — rides the `language` attribute instead and the fence carries no info
|
`adf:`, or holding a backtick, a backslash, a control character, edge whitespace or an entity
|
||||||
string; writing it in the slot that rule leaves empty, or in both, is a named error. The body is
|
reference — rides the `language` attribute instead and the fences carry no info string, and so
|
||||||
one ordinary code block, and a fence's info string decodes escapes and entity references as any
|
does a language beside `content=empty`, which has no fence; writing it in the slot that rule
|
||||||
other does.
|
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
|
- `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
|
the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no
|
||||||
CommonMark spelling.
|
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
|
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
|
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 — adjacent text
|
parsing to anything but one text node carrying neither marks, attributes nor content is a named
|
||||||
nodes with identical marks and no attributes merged first — is a named error, and so is a `text` key
|
error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the slot. The rest take no content,
|
||||||
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.
|
`!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).
|
||||||
@@ -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
|
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="…"}`,
|
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
|
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;
|
cells trim and pad. The emitter wraps the whitespace run alone and leaves the rest plain text, which
|
||||||
`markdownToAdf` merges adjacent text nodes carrying identical marks and no attributes
|
the spelled run joins on reading. Input reads that spelling alone: the value is one run of spaces
|
||||||
(`docs/decisions.md` §Equality is editor-normal). Input reads that spelling alone: the value is one
|
and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries
|
||||||
run of spaces and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark
|
plainly — is a named error.
|
||||||
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.
|
!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
|
## Marks
|
||||||
|
|
||||||
An inline node's marks ride the spelling wrapped around them, never the block sections' reserved
|
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,
|
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
|
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 —
|
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
|
`docs/decisions.md` §Equality is deep restores the array, not a set — and opens each spelling once
|
||||||
spelling once over the longest run of adjacent inline nodes carrying an identical mark, attributes
|
over the longest run of adjacent inline nodes carrying an identical mark, attributes included, at
|
||||||
included, at that depth. A run breaks at every node the emitter carries, so no emitted carry sits
|
that depth: `attrs: {}` differs from no `attrs`, and a directive spells it `{attrs=empty}`. A run
|
||||||
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 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
|
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
|
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
|
intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the
|
||||||
|
|||||||
+46
-1
@@ -1,6 +1,7 @@
|
|||||||
import type { ConvertFault } from '../result.ts'
|
import type { ConvertFault } from '../result.ts'
|
||||||
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
|
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
|
||||||
import { largestNesting } from '../nesting.ts'
|
import { largestNesting } from '../nesting.ts'
|
||||||
|
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||||
|
|
||||||
export type AdfAttributes = { [key: string]: JsonValue }
|
export type AdfAttributes = { [key: string]: JsonValue }
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ export type AdfNode = {
|
|||||||
type: string
|
type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EmptyKey = 'attrs' | 'content' | 'marks'
|
||||||
|
|
||||||
export type AdfDocument = {
|
export type AdfDocument = {
|
||||||
content?: AdfNode[]
|
content?: AdfNode[]
|
||||||
type: 'doc'
|
type: 'doc'
|
||||||
@@ -49,10 +52,45 @@ export function attributeNestingMessage(key: string, type: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
|
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)
|
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.
|
// 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 {
|
export function isAdfDocument(value: unknown): value is AdfDocument {
|
||||||
const fault = adfDocumentFault(value)
|
const fault = adfDocumentFault(value)
|
||||||
@@ -99,6 +137,13 @@ function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
|
|||||||
return true
|
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 {
|
function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
|
||||||
const pending: AdfNode[] = [...nodes]
|
const pending: AdfNode[] = [...nodes]
|
||||||
while (pending.length > 0) {
|
while (pending.length > 0) {
|
||||||
|
|||||||
@@ -1,34 +1,25 @@
|
|||||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './document.ts'
|
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './document.ts'
|
||||||
import type { JsonValue } from '../json-value.ts'
|
import type { JsonValue } from '../json-value.ts'
|
||||||
import { nodeAttrs, nodeContent, nodeMarks } from './document.ts'
|
import { identicalMark, identicalMarks, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from './document.ts'
|
||||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
|
||||||
|
|
||||||
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
|
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
|
||||||
|
|
||||||
type NodeHolder = { content?: AdfNode[] }
|
type NodeHolder = { content?: AdfNode[] }
|
||||||
|
|
||||||
export function sameMark(candidate: AdfMark, mark: AdfMark): boolean {
|
export function sameMark(left: AdfMark, right: AdfMark): boolean {
|
||||||
return markKey(candidate) === markKey(mark)
|
return identicalMark(normalMark(left), normalMark(right))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeAdjacentText(nodes: readonly AdfNode[]): AdfNode[] {
|
export function joinsNormally(previous: AdfNode, node: AdfNode): boolean {
|
||||||
const merged: AdfNode[] = []
|
if (!mergesText(previous) || !mergesText(node)) return false
|
||||||
for (const node of nodes) {
|
return identicalMarks(nodeMarks(previous).map(normalMark), nodeMarks(node).map(normalMark))
|
||||||
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 toEditorNormal(document: AdfDocument): AdfDocument {
|
export function toEditorNormal(document: AdfDocument): AdfDocument {
|
||||||
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
|
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 }]
|
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
|
||||||
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
|
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
|
if (content.length === 0) continue
|
||||||
entry.holder.content = content.map((source) => {
|
entry.holder.content = content.map((source) => {
|
||||||
const holder = normalNode(source)
|
const holder = normalNode(source)
|
||||||
@@ -76,15 +67,3 @@ function normalValue(value: JsonValue, pending: JsonContainer[]): JsonValue {
|
|||||||
function mergesText(node: AdfNode): boolean {
|
function mergesText(node: AdfNode): boolean {
|
||||||
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
|
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')}`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling)
|
|||||||
const { depth, value: held } = next
|
const { depth, value: held } = next
|
||||||
if (Array.isArray(held)) schedule(pending, '[', held.map((item) => ({ label: '', value: item })), ']', indent, depth)
|
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 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('')
|
return text.join('')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ test('a generated document refuses to emit, or its markdown reads back to it', {
|
|||||||
if (!emitted.ok) return
|
if (!emitted.ok) return
|
||||||
const read = markdownToAdf(emitted.value)
|
const read = markdownToAdf(emitted.value)
|
||||||
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(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),
|
propertyRuns(gateRuns),
|
||||||
)
|
)
|
||||||
@@ -62,3 +62,12 @@ test('a generated document writes plain markdown refusing only what the guard re
|
|||||||
propertyRuns(gateRuns),
|
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),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { isAdfDocument } from '../adf/document.ts'
|
|||||||
import { isJsonValue } from '../json-value.ts'
|
import { isJsonValue } from '../json-value.ts'
|
||||||
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||||
import { toEditorNormal } from '../adf/editor-normal.ts'
|
|
||||||
|
|
||||||
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus')
|
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus')
|
||||||
const errorsRoot = join(corpusRoot, 'errors')
|
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`)
|
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
|
||||||
const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
|
const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
|
||||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
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`)
|
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
|
||||||
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
|
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
|
||||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
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)
|
const emitted = adfToMarkdown(result.value)
|
||||||
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
||||||
const again = markdownToAdf(emitted.value)
|
const again = markdownToAdf(emitted.value)
|
||||||
assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`)
|
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}`)
|
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
||||||
const parsed = markdownToAdf(emitted.value)
|
const parsed = markdownToAdf(emitted.value)
|
||||||
assert.ok(parsed.ok, parsed.ok ? '' : `${parsed.error.code}: ${parsed.error.message}`)
|
assert.ok(parsed.ok, parsed.ok ? '' : `${parsed.error.code}: ${parsed.error.message}`)
|
||||||
assert.deepEqual(toEditorNormal(parsed.value), payload)
|
assert.deepEqual(parsed.value, payload)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
|||||||
import { nodeContent, nodeMarks } from '../adf/document.ts'
|
import { nodeContent, nodeMarks } from '../adf/document.ts'
|
||||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||||
import { textDirectiveName } from '../markdown/text-directive.ts'
|
import { textDirectiveName } from '../markdown/text-directive.ts'
|
||||||
import { toEditorNormal } from '../adf/editor-normal.ts'
|
|
||||||
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
|
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
|
||||||
|
|
||||||
type Choice = { arbitrary: Arbitrary<string>; hostile?: true; weight: number }
|
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
|
if (holdsDirectiveShape(parsed.value)) directiveShaped += 1
|
||||||
const read = markdownToAdf(emitted.value)
|
const read = markdownToAdf(emitted.value)
|
||||||
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(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)
|
const respelled = adfToMarkdown(read.value)
|
||||||
assert.ok(respelled.ok, respelled.ok ? '' : `${respelled.error.code}: ${respelled.error.message} — spelling ${JSON.stringify(emitted.value)} again`)
|
assert.ok(respelled.ok, respelled.ok ? '' : `${respelled.error.code}: ${respelled.error.message} — spelling ${JSON.stringify(emitted.value)} again`)
|
||||||
assert.equal(respelled.value, emitted.value)
|
assert.equal(respelled.value, emitted.value)
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ import assert from 'node:assert/strict'
|
|||||||
import { env } from 'node:process'
|
import { env } from 'node:process'
|
||||||
import fc from 'fast-check'
|
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 { Arbitrary } from 'fast-check'
|
||||||
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
||||||
import type { JsonValue } from '../json-value.ts'
|
import type { JsonValue } from '../json-value.ts'
|
||||||
import { blockArgument } from '../markdown/block-directive.ts'
|
import { blockArgument } from '../markdown/block-directive.ts'
|
||||||
import { blockNodes } from '../adf/block-nodes.ts'
|
import { blockNodes } from '../adf/block-nodes.ts'
|
||||||
import { directivePrefix } from '../markdown/directive-syntax.ts'
|
import { directivePrefix } from '../markdown/directive-syntax.ts'
|
||||||
|
import { emptyKeys, mergeAdjacentText } from '../adf/document.ts'
|
||||||
import { inlineNodes } from '../adf/inline-nodes.ts'
|
import { inlineNodes } from '../adf/inline-nodes.ts'
|
||||||
|
import { joinsNormally } from '../adf/editor-normal.ts'
|
||||||
import { markAttributes } from '../adf/mark-attributes.ts'
|
import { markAttributes } from '../adf/mark-attributes.ts'
|
||||||
import { toEditorNormal } from '../adf/editor-normal.ts'
|
|
||||||
|
|
||||||
type Positions = { block: AdfNode; inline: AdfNode }
|
type Positions = { block: AdfNode; inline: AdfNode }
|
||||||
|
|
||||||
@@ -36,7 +37,11 @@ export function textOf(minLength: number): Arbitrary<string> {
|
|||||||
|
|
||||||
const text = textOf(1)
|
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 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.
|
// 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
|
const keyPiece = fc
|
||||||
@@ -73,19 +78,35 @@ function heldAttributes(held: Readonly<Record<string, JsonValue | undefined>>):
|
|||||||
return attrs
|
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 {
|
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
|
||||||
const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))]
|
const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))]
|
||||||
return { content: rows.map((content): AdfNode => ({ content, type: 'tableRow' })), type: 'table' }
|
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: 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: 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 },
|
{ 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 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
|
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') }) })
|
.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' }))
|
.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]) =>
|
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 }[] {
|
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 positions = fc.letrec<Positions>((tie) => {
|
||||||
const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 })
|
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 = {
|
const contentByModel = {
|
||||||
block: blockContent,
|
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,
|
inline: inlineContent,
|
||||||
none: fc.constant<AdfNode[]>([]),
|
none: fc.constant<AdfNode[]>([]),
|
||||||
}
|
}
|
||||||
@@ -116,35 +137,40 @@ const positions = fc.letrec<Positions>((tie) => {
|
|||||||
const blockArbitraries = Object.entries(blockNodes).map(([type, model]) => {
|
const blockArbitraries = Object.entries(blockNodes).map(([type, model]) => {
|
||||||
const argument = blockArgument(type)
|
const argument = blockArgument(type)
|
||||||
const vocabulary: AttributeVocabulary = argument === undefined ? model.attributes : { ...model.attributes, [argument]: 'string' }
|
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 }
|
return { leaf: model.contentModel === 'code' || model.contentModel === 'none', node }
|
||||||
})
|
})
|
||||||
const unknownNode = fc
|
const unknownNode = occasionallyEmpty(
|
||||||
.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType })
|
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 leafBlocks = blockArbitraries.filter((entry) => entry.leaf).map((entry) => entry.node)
|
const leafBlocks = blockArbitraries.filter((entry) => entry.leaf).map((entry) => entry.node)
|
||||||
const containerBlocks = blockArbitraries.filter((entry) => !entry.leaf).map((entry) => entry.node)
|
const containerBlocks = blockArbitraries.filter((entry) => !entry.leaf).map((entry) => entry.node)
|
||||||
const misplacedWeight = 7
|
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 cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type }))
|
||||||
const listItems = fc.array(
|
const listItems = fc.array(
|
||||||
blockContent.map((content): AdfNode => ({ content, type: 'listItem' })),
|
occasionallyEmpty(blockContent.map((content): AdfNode => ({ content, type: 'listItem' }))),
|
||||||
{ depthIdentifier, maxLength: 3, minLength: 1 },
|
{ depthIdentifier, maxLength: 3, minLength: 1 },
|
||||||
)
|
)
|
||||||
const flatCommonMarkShapes = [
|
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,
|
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),
|
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[]>) =>
|
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 taskItem = fc.oneof({ arbitrary: task('taskItem', inlineContent), weight: 3 }, { arbitrary: task('blockTaskItem', blockContent), weight: 1 })
|
||||||
const nestingCommonMarkShapes = [
|
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' })),
|
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' })),
|
listItems.map((content): AdfNode => ({ content, type: 'bulletList' })),
|
||||||
fc
|
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' })),
|
.map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })),
|
||||||
]
|
]
|
||||||
const flatBlocks = [...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)]
|
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 } {
|
export function propertyRuns(gateRuns: number): { gate: boolean; numRuns: number; seed?: number } {
|
||||||
const deepRuns = env[deepRunsVariable]
|
const deepRuns = env[deepRunsVariable]
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import type { AdfMark } from '../adf/document.ts'
|
|||||||
import type { BlockType } from '../adf/block-nodes.ts'
|
import type { BlockType } from '../adf/block-nodes.ts'
|
||||||
import type { JsonValue } from '../json-value.ts'
|
import type { JsonValue } from '../json-value.ts'
|
||||||
import { blockNodeModel } from '../adf/block-nodes.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 { serializeCanonicalJson } from '../canonical-json.ts'
|
||||||
import { spellDirectiveOpener } from './directive-syntax.ts'
|
import { spellAttributes, spellDirectiveOpener } from './directive-syntax.ts'
|
||||||
|
|
||||||
const argumentByType = new Map(
|
const argumentByType = new Map(
|
||||||
Object.entries({
|
Object.entries({
|
||||||
@@ -14,6 +14,13 @@ const argumentByType = new Map(
|
|||||||
} satisfies Partial<Record<BlockType, string>>),
|
} 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 listBreakName = 'listBreak'
|
||||||
|
|
||||||
export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '')
|
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 {
|
export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined {
|
||||||
if (name === listBreakName) return 'leaf'
|
if (name === listBreakName || name === documentName) return 'leaf'
|
||||||
const model = blockNodeModel(name)
|
const model = blockNodeModel(name)
|
||||||
if (model === undefined) return undefined
|
if (model === undefined) return undefined
|
||||||
return model.contentModel === 'none' ? 'leaf' : 'container'
|
return model.contentModel === 'none' ? 'leaf' : 'container'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markValues(marks: readonly AdfMark[]): JsonValue {
|
export function markValues(marks: readonly AdfMark[]): JsonValue {
|
||||||
return marks.map((mark) => {
|
return marks.map((mark) => (mark.attrs === undefined ? { type: mark.type } : { attrs: mark.attrs, type: mark.type }))
|
||||||
const attrs = nodeAttrs(mark)
|
|
||||||
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readMarkValues(value: JsonValue): AdfMark[] | undefined {
|
export function readMarkValues(value: JsonValue): AdfMark[] | undefined {
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import type { JsonValue } from '../json-value.ts'
|
import type { JsonValue } from '../json-value.ts'
|
||||||
import { carryName } from './opaque-carry.ts'
|
import { carryFenceType } from './opaque-carry.ts'
|
||||||
import { holdsControlCharacter } from './commonmark/grammar.ts'
|
import { infoStringCarries } from './commonmark/grammar.ts'
|
||||||
import { holdsEntityReference } from './commonmark/entity-references.ts'
|
|
||||||
|
|
||||||
export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' }
|
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.
|
// spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides.
|
||||||
export function languageSlot(language: JsonValue | undefined): LanguageSlot {
|
export function languageSlot(language: JsonValue | undefined): LanguageSlot {
|
||||||
if (language === undefined) return { kind: 'none' }
|
if (language === undefined) return { kind: 'none' }
|
||||||
if (typeof language !== 'string' || language === '' || language === carryName) return { kind: 'attribute' }
|
if (typeof language !== 'string' || carryFenceType(language) !== undefined || !infoStringCarries(language)) return { kind: 'attribute' }
|
||||||
if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return { kind: 'attribute' }
|
|
||||||
return { info: language, kind: 'fence' }
|
return { info: language, kind: 'fence' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readEntityReference, replacementCharacter } from './entity-references.ts'
|
import { holdsEntityReference, readEntityReference, replacementCharacter } from './entity-references.ts'
|
||||||
|
|
||||||
export type LinePosition = 'first' | 'later'
|
export type LinePosition = 'first' | 'later'
|
||||||
|
|
||||||
@@ -133,6 +133,11 @@ export function holdsControlCharacter(text: string): boolean {
|
|||||||
return controlCharacter.test(text)
|
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 {
|
export function holdsNullCharacter(text: string): boolean {
|
||||||
return nullCharacter.test(text)
|
return nullCharacter.test(text)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export function spellStringAttribute(text: string): string {
|
|||||||
export function spellAttributeValue(value: VocabularyValue): string {
|
export function spellAttributeValue(value: VocabularyValue): string {
|
||||||
if (value.kind === 'boolean') return `${value.value}`
|
if (value.kind === 'boolean') return `${value.value}`
|
||||||
if (value.kind === 'json') return spellJsonAttribute(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)
|
return spellStringAttribute(value.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ import type { JsonValue } from '../../json-value.ts'
|
|||||||
import type { Result } from '../../result.ts'
|
import type { Result } from '../../result.ts'
|
||||||
import { adfToMarkdown, markdownToAdf } from '../../index.ts'
|
import { adfToMarkdown, markdownToAdf } from '../../index.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { toEditorNormal } from '../../adf/editor-normal.ts'
|
|
||||||
|
|
||||||
function document(...content: AdfNode[]): AdfDocument {
|
function document(...content: AdfNode[]): AdfDocument {
|
||||||
return { content, type: 'doc', version: 1 }
|
return { content, type: 'doc', version: 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
function paragraph(...content: AdfNode[]): AdfNode {
|
function paragraph(...content: AdfNode[]): AdfNode {
|
||||||
return { content, type: 'paragraph' }
|
return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function code(result: Result<string>): string {
|
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' }
|
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')
|
assert.equal(markdown(adfToMarkdown(document(nested))), '- - x\n\n !adf:listBreak\n\n - x\n')
|
||||||
const carried: AdfNode = { ...list, attrs: { unknown: 'x' } }
|
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(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', () => {
|
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' }))), '```carry\n{\n "type": "blockCard"\n}\n```\n')
|
assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```adf:blockCard\n{}\n```\n')
|
||||||
assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```carry\n{\n "type": "toString"\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(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({ text: 'x', type: 'text' }))), '```adf:text\n{\n "text": "x"\n}\n```\n')
|
||||||
assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```carry\n{\n "type": "hardBreak"\n}\n```\n')
|
assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```adf:hardBreak\n{}\n```\n')
|
||||||
|
assert.equal(markdown(adfToMarkdown(document({ type: 'a&b' }))), '```adf:\n{\n "type": "a&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', () => {
|
test('spells the code block whose language opens with the reserved info string prefix', () => {
|
||||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '!adf:codeBlock {language=carry}\n```\n```\n!adf:/codeBlock\n')
|
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')
|
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')
|
assert.equal(code(adfToMarkdown(document(quoted))), 'unsupported-nesting-depth')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a node whose content model the canonical form cannot emit', () => {
|
test('carries a code block holding a node no fence holds', () => {
|
||||||
assert.equal(
|
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), '```adf:codeBlock\n{\n "content": [\n {\n "type": "paragraph"\n }\n ]\n}\n```\n')
|
||||||
markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))),
|
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' }]) {
|
||||||
'unsupported-node-shape: a codeBlock holds plain text nodes only: this paragraph node is not one',
|
assert.ok(markdown(adfToMarkdown(document({ content: [child], type: 'codeBlock' }))).startsWith('```adf:codeBlock\n'), JSON.stringify(child))
|
||||||
)
|
}
|
||||||
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('spells a list its own content shape cannot hold as a directive', () => {
|
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({ 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({ 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', () => {
|
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', () => {
|
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 text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
|
||||||
const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' })
|
const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' })
|
||||||
const ordered: AdfNode = { attrs: { order: 2 }, content: [item(text('b'))], type: 'orderedList' }
|
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 list: AdfNode = { content: [item(text('b'))], type: 'bulletList' }
|
||||||
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [text('p')], type: 'panel' }
|
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.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', () => {
|
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)
|
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
|
||||||
const read = markdownToAdf(spelled.value)
|
const read = markdownToAdf(spelled.value)
|
||||||
assert.ok(read.ok, read.ok ? '' : read.error.message)
|
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'))
|
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', () => {
|
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')
|
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' }
|
const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' }
|
||||||
assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n')
|
assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n')
|
||||||
@@ -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(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(code(adfToMarkdown(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))), 'unspellable-character')
|
||||||
assert.equal(
|
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape: a text node holds text: this one has none')
|
||||||
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',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a text node the spelling would empty out', () => {
|
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')
|
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' }
|
const code_ = { type: 'code' }
|
||||||
assert.equal(
|
assert.equal(
|
||||||
markdown(adfToMarkdown(document(paragraph({ marks: [code_], text: 'a', type: 'text' }, { marks: [code_], text: 'b', type: 'text' })))),
|
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', () => {
|
test('spells a block directive as its node type, arg and attributes', () => {
|
||||||
const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' })
|
const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' })
|
||||||
assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), '!adf:panel warning\nx\n!adf:/panel\n')
|
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({ 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({ type: 'caption' }))), '!adf:caption\n!adf:/caption\n')
|
||||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '!adf:syncBlock {localId=a}\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', () => {
|
test('carries a directive attribute no section spells', () => {
|
||||||
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
|
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: { rounded: true }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "rounded": true\n }\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: { toString: 'x' }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "toString": "x"\n }\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: { localId: 4 }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "localId": 4\n }\n}\n```\n')
|
||||||
assert.equal(
|
assert.equal(
|
||||||
carried({ attrs: { width: '50' }, type: 'layoutColumn' }),
|
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(
|
assert.equal(
|
||||||
carried({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }),
|
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(
|
assert.equal(
|
||||||
carried({ content: [{ attrs: { alt: 4 }, type: 'media' }], type: 'mediaGroup' }),
|
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)))
|
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
|
||||||
assert.equal(
|
assert.equal(
|
||||||
carried({ attrs: { panelType: 'extra info' }, type: 'panel' }),
|
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', () => {
|
test('carries a block node mark in the reserved attribute', () => {
|
||||||
const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' })
|
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({ 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', () => {
|
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', () => {
|
test('spells the image form for exactly the centered external media shape', () => {
|
||||||
const url = 'https://example.com/moon.png'
|
const url = 'https://example.com/moon.png'
|
||||||
const single = (attrs: AdfAttributes, ...content: AdfNode[]): AdfDocument =>
|
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 }))), `\n`)
|
assert.equal(markdown(adfToMarkdown(single({ alt: 'The moon', type: 'external', url }))), `\n`)
|
||||||
assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `\n`)
|
assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `\n`)
|
||||||
assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${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 emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||||
const underline: AdfMark = { type: 'underline' }
|
const underline: AdfMark = { type: 'underline' }
|
||||||
assert.equal(emitted(marked('x', underline)), '!adf:underline[x]\n')
|
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: { 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: '#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')
|
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", () => {
|
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' }))
|
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`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||||
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
||||||
import type { Flavour } from '../plain-conventions.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 { 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 { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
|
||||||
import { carriedBlock } from '../opaque-carry.ts'
|
import { carriedBlock } from '../opaque-carry.ts'
|
||||||
import { emitInlineLine } from './inline-line.ts'
|
import { emitInlineLine } from './inline-line.ts'
|
||||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
import { fencedCodeBlock } from '../commonmark/backtick-runs.ts'
|
import { fencedCodeBlock } from '../commonmark/backtick-runs.ts'
|
||||||
import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.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 { largestNesting } from '../../nesting.ts'
|
||||||
import { spellBlockDirectiveOpener } from './block-directive-spelling.ts'
|
import { spellBlockDirectiveOpener } from './block-directive-spelling.ts'
|
||||||
import { spellDirectiveCloser, spellDirectiveOpener } from '../directive-syntax.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)
|
const fault = adfDocumentFault(document)
|
||||||
if (fault !== undefined) return faulted(fault, [])
|
if (fault !== undefined) return faulted(fault, [])
|
||||||
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
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
|
if (!walk.ok) return walk
|
||||||
const text = joinBlocks(walk.value.blocks, 'document')
|
const text = joinBlocks(walk.value.blocks, 'document')
|
||||||
return success(text === '' ? '' : `${text}\n`)
|
return success(text === '' ? '' : `${text}\n`)
|
||||||
@@ -254,40 +255,39 @@ function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBloc
|
|||||||
if (!carriesOnly(node, ['language'])) return undefined
|
if (!carriesOnly(node, ['language'])) return undefined
|
||||||
const slot = languageSlot(nodeAttrs(node)['language'])
|
const slot = languageSlot(nodeAttrs(node)['language'])
|
||||||
if (slot.kind === 'attribute') return undefined
|
if (slot.kind === 'attribute') return undefined
|
||||||
const text = codeBlockText(node, path)
|
const texts = fencedTexts(node, path)
|
||||||
if (!text.ok) return text
|
if (!texts.ok) return texts
|
||||||
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
|
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> {
|
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'])
|
const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language'])
|
||||||
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||||
if (!opener.ok) return opener
|
if (!opener.ok) return opener
|
||||||
const text = codeBlockText(node, path)
|
const info = slot.kind === 'fence' ? slot.info : ''
|
||||||
if (!text.ok) return text
|
return success(directivePair(node, opener.value, texts.value.map((text) => fencedCodeBlock(info, text)).join('\n')))
|
||||||
return success(directivePair(node, opener.value, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
// The text each fence holds, or `undefined` where a child is no plain text node, which the carry holds instead.
|
||||||
let text = ''
|
function fencedTexts(node: AdfNode, path: ConvertErrorPath): Result<string[] | undefined> {
|
||||||
for (const [index, child] of nodeContent(node).entries()) {
|
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]
|
const childPath = [...path, 'content', index]
|
||||||
if (
|
if (typeof child.text !== 'string' || child.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', childPath)
|
||||||
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 (/\r/.test(child.text)) return failure('unspellable-character', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', 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)
|
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 {
|
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 {
|
function listStart(node: AdfNode, items: number): number | undefined {
|
||||||
if (node.type !== 'orderedList') return 0
|
if (node.type !== 'orderedList') return 0
|
||||||
const start = nodeAttrs(node)['order']
|
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
|
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 { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
|
import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
|
||||||
import { overNested } from '../../json-value.ts'
|
import { overNested } from '../../json-value.ts'
|
||||||
|
import { spellEmptyKeys } from '../empty-keys.ts'
|
||||||
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
|
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
|
||||||
|
|
||||||
export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, spelledByBody: readonly string[] = []): Result<string> | undefined {
|
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 spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
|
||||||
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, spelled)
|
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, spelled)
|
||||||
if (pairs === undefined) return undefined
|
if (pairs === undefined) return undefined
|
||||||
const spelledPairs = spellVocabulary(pairs)
|
const spelledPairs = [...spellVocabulary(pairs), ...spellEmptyKeys(node)]
|
||||||
const marks = nodeMarks(node)
|
const marks = nodeMarks(node)
|
||||||
if (marks.length > 0) {
|
if (marks.length > 0) {
|
||||||
const values = markValues(marks)
|
const values = markValues(marks)
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import type { AdfNode } from '../../adf/document.ts'
|
|||||||
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
|
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||||
import { nodeAttrs } from '../../adf/document.ts'
|
import { nodeAttrs } from '../../adf/document.ts'
|
||||||
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
|
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
|
||||||
|
import { spellEmptyKeys } from '../empty-keys.ts'
|
||||||
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
|
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
|
||||||
|
|
||||||
export function spellInlineNodeAttributes(node: AdfNode, model: InlineNodeModel): string | undefined {
|
export function spellInlineNodeAttributes(node: AdfNode, model: InlineNodeModel): string | undefined {
|
||||||
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, model.textAttribute === undefined ? [] : [model.textAttribute])
|
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)])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment,
|
|||||||
import { carriedInline } from '../opaque-carry.ts'
|
import { carriedInline } from '../opaque-carry.ts'
|
||||||
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||||
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.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 { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
|
||||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
import { highlightDelimiter } from '../plain-conventions.ts'
|
import { highlightDelimiter } from '../plain-conventions.ts'
|
||||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
|
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
|
||||||
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
import { readsAsOne, textBreakSpelling } from '../text-break.ts'
|
||||||
import { sameMark } from '../../adf/editor-normal.ts'
|
|
||||||
import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
|
import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
|
||||||
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
|
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
|
||||||
import { spellTextDirective } from '../text-directive.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 runs = inlineRuns(nodes, depth, firstIndex, context.carried)
|
||||||
const segments: InlineSegment[] = []
|
const segments: InlineSegment[] = []
|
||||||
for (const [offset, run] of runs.entries()) {
|
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 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)
|
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
|
if (!emitted.ok) return emitted
|
||||||
@@ -206,12 +207,18 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const previous = runs[runs.length - 1]
|
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] })
|
else runs.push({ index, kind: 'marked', mark, nodes: [node] })
|
||||||
}
|
}
|
||||||
return runs
|
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 {
|
function nodePath(context: InlineContext, index: number): ConvertErrorPath {
|
||||||
return [...context.path, 'content', index]
|
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> {
|
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 (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 (/\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)
|
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'
|
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)
|
if (mark.type === 'backgroundColor' && context.flavour === 'plain') return emitHighlight(nodes, depth, range, context)
|
||||||
const spelling = markSpelling(mark.type)
|
const spelling = markSpelling(mark.type)
|
||||||
if (spelling === undefined) return success({ carry: range })
|
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 (attributes === undefined) return success({ carry: range })
|
||||||
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path)
|
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path)
|
||||||
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
|
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> {
|
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
|
||||||
let text = ''
|
const spans: string[] = []
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.type !== 'text' || nodeMarks(node).length !== depth + 1) return success({ carry: range })
|
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 (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
if (!isPlainText(node) || nodeMarks(node).length !== depth + 1) return success({ carry: range })
|
||||||
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
|
const { text } = node
|
||||||
text += node.text
|
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)
|
||||||
|
spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`)
|
||||||
}
|
}
|
||||||
if (/[\n\r]/.test(text)) return success({ carry: range })
|
return success({ segments: [syntax(spans.join(textBreakSpelling))] })
|
||||||
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}`)] })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function needsPadding(text: string): boolean {
|
function needsPadding(text: string): boolean {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import type { LineContainer } from '../line-container.ts'
|
|||||||
import type { MarkRun } from './line-escaping.ts'
|
import type { MarkRun } from './line-escaping.ts'
|
||||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.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 { largestNesting } from '../../nesting.ts'
|
||||||
import { mergeAdjacentText, sameMark } from '../../adf/editor-normal.ts'
|
import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||||
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
|
||||||
import { plainLineFallback, type PlainLineFallback } from './inline-line.ts'
|
import { plainLineFallback, type PlainLineFallback } from './inline-line.ts'
|
||||||
import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.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[] {
|
function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] {
|
||||||
for (let current = leaves; ; ) {
|
for (let current = leaves; ; ) {
|
||||||
const merged = withoutEdgeBreaks(mergeAdjacentText(current))
|
const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsNormally))
|
||||||
let changed = false
|
let changed = false
|
||||||
const trimmed: AdfNode[] = []
|
const trimmed: AdfNode[] = []
|
||||||
for (const [index, leaf] of merged.entries()) {
|
for (const [index, leaf] of merged.entries()) {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ test('refuses what the document guard refuses, and nothing else', () => {
|
|||||||
let deep: AdfNode = said('x')
|
let deep: AdfNode = said('x')
|
||||||
for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'layoutColumn' }
|
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(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')
|
let deepInline: AdfNode = text('x')
|
||||||
for (let level = 0; level <= largestNesting; level += 1) deepInline = { content: [deepInline], type: 'unknownInline' }
|
for (let level = 0; level <= largestNesting; level += 1) deepInline = { content: [deepInline], type: 'unknownInline' }
|
||||||
assert.match(plainDocument(document(paragraph(deepInline))), /^unsupported-nesting-depth at /)
|
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(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({ 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: '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('codeBlock', {})), '```\n```\n')
|
||||||
assert.equal(plain(node('rule', { color: '#000' })), '---\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')
|
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))
|
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' }))
|
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')
|
||||||
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
|||||||
import { languageSlot } from '../code-language.ts'
|
import { languageSlot } from '../code-language.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { taskMarker } from '../plain-conventions.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.
|
// depth: the level the node reduced stands at, counted as the emitter counts it.
|
||||||
type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
|
type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
|
||||||
@@ -50,8 +51,14 @@ export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
|
|||||||
const fault = adfDocumentFault(document)
|
const fault = adfDocumentFault(document)
|
||||||
if (fault !== undefined) return faulted(fault, [])
|
if (fault !== undefined) return faulted(fault, [])
|
||||||
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
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: [] })
|
// docs/decisions.md §Equality is deep: the reduction reads and writes editor-normal ADF.
|
||||||
return blocks.ok ? success({ content: blocks.value, type: 'doc', version: 1 }) : blocks
|
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[]> {
|
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 {
|
function standsInline(node: AdfNode): boolean {
|
||||||
if (node.type === 'text' || inlineNodeModel(node.type) !== undefined) return true
|
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[]> {
|
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.
|
// 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 {
|
function itemOf(blocks: readonly AdfNode[]): AdfNode {
|
||||||
const rules = blocks.findIndex((block) => block.type !== 'rule')
|
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[] {
|
function blankedCode(blocks: readonly AdfNode[]): AdfNode[] {
|
||||||
|
|||||||
@@ -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,6 +7,7 @@ import { holdsEntityReference } from './commonmark/entity-references.ts'
|
|||||||
import { isAutolink } from './commonmark/grammar.ts'
|
import { isAutolink } from './commonmark/grammar.ts'
|
||||||
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
|
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
|
||||||
import { nodeAttrs, nodeMarks } from '../adf/document.ts'
|
import { nodeAttrs, nodeMarks } from '../adf/document.ts'
|
||||||
|
import { spellEmptyKeys } from './empty-keys.ts'
|
||||||
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
|
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
|
||||||
|
|
||||||
type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string }
|
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
|
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 {
|
// `undefined` where the spelling holds no such attrs: only a directive spells attrs=empty.
|
||||||
const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, [])
|
export function spellMarkAttributes(mark: AdfMark, spelling: MarkSpelling): string | undefined {
|
||||||
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
|
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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,49 @@
|
|||||||
import type { AdfNode } from '../adf/document.ts'
|
import type { AdfNode } from '../adf/document.ts'
|
||||||
import type { DirectiveSpan, Read } from './directive-syntax.ts'
|
import type { DirectiveSpan, Read } from './directive-syntax.ts'
|
||||||
import type { JsonSpelling } from '../canonical-json.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 { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
|
||||||
import { fencedCodeBlock } from './commonmark/backtick-runs.ts'
|
import { fencedCodeBlock } from './commonmark/backtick-runs.ts'
|
||||||
|
import { infoStringCarries } from './commonmark/grammar.ts'
|
||||||
import { isAdfNode } from '../adf/document.ts'
|
import { isAdfNode } from '../adf/document.ts'
|
||||||
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
|
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
|
||||||
import { largestNesting } from '../nesting.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'
|
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||||
|
|
||||||
|
export const carryFencePrefix = 'adf:'
|
||||||
|
|
||||||
export const carryName = 'carry'
|
export const carryName = 'carry'
|
||||||
|
|
||||||
const jsonAttribute = 'json'
|
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 }> {
|
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
|
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> {
|
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
|
if (!json.ok) return json
|
||||||
return success(spellInlineLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value.json)]])))
|
return success(spellInlineLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value.json)]])))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readCarriedBlock(body: string, depth: number): Read<AdfNode> {
|
// The type a fence's info string names, `undefined` where the info string opens no carry.
|
||||||
return readCarriedJson(body, 'two-space', largestNesting - depth)
|
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 {
|
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)
|
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)
|
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 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)
|
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
|
const { value } = parsed
|
||||||
if (!isJsonValue(value)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') }
|
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` } }
|
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) {
|
if (serializeCanonicalJson(value, spelling) !== raw) {
|
||||||
const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted'
|
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") }
|
if (!isAdfNode(held)) return { fault: unsupportedNodeShape(`the opaque carry holds one ADF node's JSON: this JSON is no ADF node; ${wayOut}`) }
|
||||||
return { value }
|
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 {
|
function parseJsonText(raw: string): { value: unknown } | undefined {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import type { ConvertFault } from '../../result.ts'
|
|||||||
import type { DirectiveAttributes } from '../directive-syntax.ts'
|
import type { DirectiveAttributes } from '../directive-syntax.ts'
|
||||||
import type { MarkSpelling } from '../mark-spellings.ts'
|
import type { MarkSpelling } from '../mark-spellings.ts'
|
||||||
import { directivePrefix } from '../directive-syntax.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 { markSpelling } from '../mark-spellings.ts'
|
||||||
|
import { readEmptyKeys } from '../empty-keys.ts'
|
||||||
import { readVocabulary } from './directive-attributes.ts'
|
import { readVocabulary } from './directive-attributes.ts'
|
||||||
|
|
||||||
export function readDirectiveMark(name: string, attributes: DirectiveAttributes, path: ConvertErrorPath): Result<AdfMark> | undefined {
|
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
|
if (spelling === undefined) return undefined
|
||||||
const markdown = markdownForm(spelling)
|
const markdown = markdownForm(spelling)
|
||||||
if (markdown !== undefined) return failure('unsupported-node-shape', `${name} is spelled ${markdown}, never as a directive`, path)
|
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 (!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 })
|
return success(Object.keys(attrs.value).length === 0 ? { type: name } : { attrs: attrs.value, type: name })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { BlockNodeModel } from '../../adf/block-nodes.ts'
|
||||||
import type { ConvertFault } from '../../result.ts'
|
import type { ConvertFault } from '../../result.ts'
|
||||||
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
|
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
|
||||||
import type { Elsewhere } from './directive-attributes.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 { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
|
||||||
import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts'
|
import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts'
|
||||||
import { blockNodeModel } from '../../adf/block-nodes.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 { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
import { inlineMarkSpellingFault } from './directive-marks.ts'
|
import { inlineMarkSpellingFault } from './directive-marks.ts'
|
||||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||||
|
import { readEmptyKeys, spellsEmpty } from '../empty-keys.ts'
|
||||||
import { readVocabulary } from './directive-attributes.ts'
|
import { readVocabulary } from './directive-attributes.ts'
|
||||||
import { slotLineEndingFault } from '../directive-syntax.ts'
|
import { slotLineEndingFault } from '../directive-syntax.ts'
|
||||||
|
import { textBreakName } from '../text-break.ts'
|
||||||
import { textDirectiveName } from '../text-directive.ts'
|
import { textDirectiveName } from '../text-directive.ts'
|
||||||
|
|
||||||
export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; node: AdfNode }
|
export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; node: AdfNode }
|
||||||
@@ -24,12 +26,15 @@ export function readBlockDirectiveNode(
|
|||||||
path: ConvertErrorPath,
|
path: ConvertErrorPath,
|
||||||
): Result<BlockDirectiveNode> {
|
): Result<BlockDirectiveNode> {
|
||||||
if (name === carryName) {
|
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)
|
const model = blockNodeModel(name)
|
||||||
if (model === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path)
|
if (model === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path)
|
||||||
const argumentKey = blockArgument(name)
|
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)
|
rest.delete(marksAttribute)
|
||||||
const elsewhere: Elsewhere | undefined = argumentKey === undefined ? undefined : { key: argumentKey, slot: 'argument' }
|
const elsewhere: Elsewhere | undefined = argumentKey === undefined ? undefined : { key: argumentKey, slot: 'argument' }
|
||||||
const attrs = readVocabulary(name, rest, model.attributes, elsewhere, path)
|
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)
|
if (argumentKey === undefined) return failure('unsupported-node-shape', `${name} takes no argument: this one spells one`, path)
|
||||||
attrs.value[argumentKey] = argument
|
attrs.value[argumentKey] = argument
|
||||||
}
|
}
|
||||||
const spelled = attributes.get(marksAttribute)
|
const marks: Result<AdfMark[] | undefined> = spelled === undefined || spellsEmpty(spelled) ? success(undefined) : readMarks(name, spelled, path)
|
||||||
const marks: Result<AdfMark[] | undefined> = spelled === undefined ? success(undefined) : readMarks(name, spelled, path)
|
|
||||||
if (!marks.ok) return marks
|
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(
|
export function readInlineDirectiveNode(
|
||||||
@@ -55,7 +59,9 @@ export function readInlineDirectiveNode(
|
|||||||
const slot = model.textAttribute
|
const slot = model.textAttribute
|
||||||
if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path)
|
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 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 (!attrs.ok) return attrs
|
||||||
if (slot !== undefined && content !== undefined) {
|
if (slot !== undefined && content !== undefined) {
|
||||||
const text = slotText(content)
|
const text = slotText(content)
|
||||||
@@ -66,14 +72,14 @@ export function readInlineDirectiveNode(
|
|||||||
if (spans !== undefined) return faulted(spans, path)
|
if (spans !== undefined) return faulted(spans, path)
|
||||||
attrs.value[slot] = text
|
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).
|
// 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 {
|
function inlineSpellingFault(name: string): ConvertFault | undefined {
|
||||||
const mark = inlineMarkSpellingFault(name)
|
const mark = inlineMarkSpellingFault(name)
|
||||||
if (mark !== undefined) return mark
|
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` }
|
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 {
|
function slotText(content: readonly AdfNode[]): string | undefined {
|
||||||
if (content.length === 0) return ''
|
if (content.length === 0) return ''
|
||||||
const only = content.length === 1 ? content[0] : undefined
|
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
|
return only.text
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +106,9 @@ function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath
|
|||||||
return success(marks)
|
return success(marks)
|
||||||
}
|
}
|
||||||
|
|
||||||
function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined): AdfNode {
|
function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined, empty: ReadonlySet<EmptyKey>): AdfNode {
|
||||||
const named = Object.keys(attrs).length === 0 ? { type } : { attrs, type }
|
const node: AdfNode = Object.keys(attrs).length > 0 || empty.has('attrs') ? { attrs, type } : { type }
|
||||||
return marks === undefined ? named : { ...named, marks: [...marks] }
|
if (empty.has('content')) node.content = []
|
||||||
|
if (marks !== undefined || empty.has('marks')) node.marks = [...(marks ?? [])]
|
||||||
|
return node
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,19 +10,29 @@ import { commonMarkLink, linkHref } from '../mark-spellings.ts'
|
|||||||
import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
|
import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
|
||||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
import { highlightDelimiter, highlightFlanking } from '../plain-conventions.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 { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||||
import { mergeAdjacentText, sameMarks } from '../../adf/editor-normal.ts'
|
|
||||||
import { noSpans, readInlineDirective } from '../directive-syntax.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 { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts'
|
||||||
import { openingLinkTakesDirective } from '../emit/inline-line.ts'
|
import { openingLinkTakesDirective } from '../emit/inline-line.ts'
|
||||||
import { readCarriedInline } from '../opaque-carry.ts'
|
import { readCarriedInline } from '../opaque-carry.ts'
|
||||||
import { readDirectiveMark } from './directive-marks.ts'
|
import { readDirectiveMark } from './directive-marks.ts'
|
||||||
import { readInlineDirectiveNode } from './directive-nodes.ts'
|
import { readInlineDirectiveNode } from './directive-nodes.ts'
|
||||||
import { readTextDirective } from '../text-directive.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[] }
|
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>
|
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
|
||||||
|
|
||||||
type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
|
type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
|
||||||
@@ -33,9 +43,10 @@ type Pairing = EmphasisPairing<Run>
|
|||||||
|
|
||||||
type Piece =
|
type Piece =
|
||||||
| Bracket
|
| Bracket
|
||||||
| { kind: 'carry'; node: AdfNode }
|
| Carried
|
||||||
| { alt: string; kind: 'image'; node: AdfNode }
|
| { 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 }
|
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
|
||||||
| { closes: boolean; kind: 'highlight'; opens: boolean }
|
| { closes: boolean; kind: 'highlight'; opens: boolean }
|
||||||
|
|
||||||
@@ -56,21 +67,41 @@ type Scan = {
|
|||||||
spans: NestedSpans
|
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 editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
|
||||||
const hreflessLink = 'the link mark spells its href: this one spells none'
|
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 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 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 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> {
|
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> {
|
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'container' | 'highlights' | 'spans'>): Scan {
|
||||||
const scan: Scan = { container, deactivatedBefore: 0, definitions, highlights, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
|
return { ...shared, ...own, deactivatedBefore: 0, openingSpellableLink: false, pending: '', pieces: [], source }
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanInline(scan: Scan): Result<Scanned> {
|
||||||
|
const { source } = scan
|
||||||
let index = 0
|
let index = 0
|
||||||
while (index < source.length) {
|
while (index < source.length) {
|
||||||
switch (source.charAt(index)) {
|
switch (source.charAt(index)) {
|
||||||
@@ -103,7 +134,7 @@ function parseInline(source: string, definitions: LinkDefinitions, path: Convert
|
|||||||
index = readCharacter(scan, index)
|
index = readCharacter(scan, index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flush(scan, container !== undefined)
|
flush(scan, scan.container !== undefined)
|
||||||
return assemble(scan)
|
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)
|
if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
|
||||||
return success({ kind: 'carry', node: carried.value })
|
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)
|
const text = readTextDirective(span)
|
||||||
if (text?.fault !== undefined) return faulted(text.fault, scan.path)
|
if (text?.fault !== undefined) return faulted(text.fault, scan.path)
|
||||||
if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] })
|
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
|
if (!slot.ok) return slot
|
||||||
const mark = readDirectiveMark(span.name, span.attributes, scan.path)
|
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
|
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
|
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> {
|
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.
|
// 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))
|
const href = linkHref(nodeAttrs(mark))
|
||||||
if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.path)
|
if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.path)
|
||||||
if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, 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)
|
if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path)
|
||||||
scan.openingSpellableLink = true
|
scan.openingSpellableLink = true
|
||||||
return undefined
|
return undefined
|
||||||
@@ -242,7 +276,8 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[
|
|||||||
|
|
||||||
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
|
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
|
||||||
if (span.content === undefined) return success(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.ok) return parsed
|
||||||
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
|
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
|
||||||
return success(parsed.value)
|
return success(parsed.value)
|
||||||
@@ -258,20 +293,50 @@ function pushNode(scan: Scan, node: AdfNode): void {
|
|||||||
scan.pieces.push({ kind: 'nodes', nodes: [node] })
|
scan.pieces.push({ kind: 'nodes', nodes: [node] })
|
||||||
}
|
}
|
||||||
|
|
||||||
function assemble(scan: Scan): Result<InlineContent> {
|
function assemble(scan: Scan): Result<Scanned> {
|
||||||
const only = scan.pieces[0]
|
const only = scan.pieces[0]
|
||||||
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
|
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
|
||||||
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
|
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 (!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 })
|
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 {
|
function holdsCarry(pieces: readonly Piece[]): boolean {
|
||||||
return pieces.some((piece) => piece.kind === 'carry')
|
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))
|
return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes))
|
||||||
}
|
}
|
||||||
|
|
||||||
function marksLink(nodes: readonly AdfNode[]): boolean {
|
function marksLink(nodes: readonly Inline[]): boolean {
|
||||||
return nodes.some((node) => nodeMarks(node).some((mark) => mark.type === 'link'))
|
return adfNodes(nodes).some((node) => nodeMarks(node).some((mark) => mark.type === 'link'))
|
||||||
}
|
}
|
||||||
|
|
||||||
function readDelimiterRun(scan: Scan, index: number): number {
|
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 (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
|
||||||
if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, 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
|
if (!resolved.ok) return resolved
|
||||||
const nodes = resolved.value
|
const nodes = resolved.value
|
||||||
// An empty link text gives the mark no node to ride, so the brackets stay text.
|
// 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> {
|
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)
|
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
|
if (!resolved.ok) return resolved
|
||||||
const alt = resolved.value
|
const alt = resolved.value
|
||||||
const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination }
|
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)
|
return success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<string> {
|
function imageAlt(inner: readonly Piece[], scan: Scan): Result<string> {
|
||||||
const nodes = resolveNodes(inner, path, false)
|
const nodes = resolveNodes(inner, scan, false)
|
||||||
if (!nodes.ok) return nodes
|
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.
|
// 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.
|
// 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 nodes = pieces.map(pieceNodes)
|
||||||
const runs = delimiterRuns(pieces)
|
const runs = delimiterRuns(pieces)
|
||||||
const pairings = matchEmphasis(runs)
|
const pairings = matchEmphasis(runs)
|
||||||
writeUnpaired(nodes, runs, pairings)
|
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) : [])
|
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.
|
// 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.
|
// 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) {
|
switch (piece.kind) {
|
||||||
case 'carry':
|
case 'carry':
|
||||||
return [piece.node]
|
return [piece]
|
||||||
case 'highlight':
|
case 'highlight':
|
||||||
return [{ text: '', type: 'text' }]
|
return [{ text: '', type: 'text' }]
|
||||||
case 'image':
|
case 'image':
|
||||||
@@ -461,6 +544,8 @@ function pieceNodes(piece: Piece): AdfNode[] {
|
|||||||
return bracketNodes(piece)
|
return bracketNodes(piece)
|
||||||
case 'run':
|
case 'run':
|
||||||
return []
|
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.
|
// 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 heads = new Map<Run, number>()
|
||||||
const tails = new Map<Run, number>()
|
const tails = new Map<Run, number>()
|
||||||
for (const pairing of pairings) {
|
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).
|
// 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) {
|
for (const pairing of pairings) {
|
||||||
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
|
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
|
||||||
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) {
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightDelimiters(pieces: readonly Piece[], nodes: readonly AdfNode[][]): HighlightDelimiter[] {
|
function highlightDelimiters(pieces: readonly Piece[], nodes: readonly Inline[][]): HighlightDelimiter[] {
|
||||||
const found: HighlightDelimiter[] = []
|
const found: HighlightDelimiter[] = []
|
||||||
let line = 0
|
let line = 0
|
||||||
let position = 0
|
let position = 0
|
||||||
for (const [index, piece] of pieces.entries()) {
|
for (const [index, piece] of pieces.entries()) {
|
||||||
const held = nodes[index] ?? []
|
const held = adfNodes(nodes[index] ?? [])
|
||||||
const [holder] = held
|
const [holder] = held
|
||||||
if (piece.kind === 'highlight' && holder !== undefined) {
|
if (piece.kind === 'highlight' && holder !== undefined) {
|
||||||
found.push({ closes: piece.closes, holder, index, line, opens: piece.opens, position })
|
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.
|
// 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 found = highlightDelimiters(pieces, nodes)
|
||||||
const paired: { closer: number; opener: number }[] = []
|
const paired: { closer: number; opener: number }[] = []
|
||||||
let closer = 0
|
let closer = 0
|
||||||
@@ -531,7 +616,7 @@ function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][])
|
|||||||
let candidate = found[closer]
|
let candidate = found[closer]
|
||||||
while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) candidate = found[(closer += 1)]
|
while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) candidate = found[(closer += 1)]
|
||||||
if (candidate === undefined) break
|
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 })
|
paired.push({ closer: candidate.index, opener: opener.index })
|
||||||
resume = candidate.position + highlightDelimiter.length
|
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.
|
// 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()) {
|
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) {
|
for (const { closer, opener } of paired) {
|
||||||
nodes[opener] = []
|
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)
|
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
|
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] }
|
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).
|
// 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) => {
|
return nodes.map((node) => {
|
||||||
|
if (!isNode(node)) return node
|
||||||
const marks = nodeMarks(node)
|
const marks = nodeMarks(node)
|
||||||
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
|
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -84,9 +84,21 @@ function table(...rows: AdfNode[]): AdfNode {
|
|||||||
return { content: rows, type: 'table' }
|
return { content: rows, type: 'table' }
|
||||||
}
|
}
|
||||||
|
|
||||||
test('builds an empty document from input holding no block', () => {
|
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: { type: 'doc', version: 1 } })
|
assert.deepEqual(markdownToAdf(''), { ok: true, value: { content: [], type: 'doc', version: 1 } })
|
||||||
assert.deepEqual(content(markdownToAdf('\n \n\t\n')), [])
|
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', () => {
|
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'
|
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 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 {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)
|
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', () => {
|
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\n')), reserved)
|
||||||
assert.equal(content(markdownToAdf('!adf:carry\nx\n!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('```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\\"}"}'
|
const carried = '!adf:carry{json="{\\"type\\":\\"placeholder\\"}"}'
|
||||||
|
|
||||||
test('reads the carry fence back to the node its JSON holds', () => {
|
test('reads the carry fence back to the node its info string names and its JSON holds', () => {
|
||||||
assert.deepEqual(content(markdownToAdf('```carry\n{\n "attrs": {\n "url": "https://example.com/x"\n },\n "type": "blockCard"\n}\n```\n')), [
|
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' },
|
{ 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', () => {
|
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', () => {
|
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 invalid = 'malformed-directive: the opaque carry holds invalid JSON; '
|
||||||
assert.equal(content(markdownToAdf('```carry\n{"type":\n```\n')), invalid)
|
const fence = `${invalid}!adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block`
|
||||||
assert.equal(content(markdownToAdf('```carry\n```\n')), invalid)
|
assert.equal(content(markdownToAdf('```adf:x\n{"attrs":\n```\n')), fence)
|
||||||
assert.equal(content(markdownToAdf('!adf:carry{json="{"}\n')), invalid)
|
assert.equal(content(markdownToAdf('```adf:x\n```\n')), fence)
|
||||||
assert.equal(content(markdownToAdf('!adf:carry{json=abc}\n')), invalid)
|
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', () => {
|
test('names the canonical spelling a carried JSON reads alone', () => {
|
||||||
const canonically = "unsupported-node-shape: the opaque carry spells its node's JSON canonically: "
|
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`)
|
const literal = '; \\!adf: keeps the prefix literal'
|
||||||
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted`)
|
assert.equal(
|
||||||
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted`)
|
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', () => {
|
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"
|
const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node; "
|
||||||
assert.equal(content(markdownToAdf('```carry\n[]\n```\n')), node)
|
assert.equal(content(markdownToAdf('```adf:x\n[]\n```\n')), `${node}!adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block`)
|
||||||
assert.equal(content(markdownToAdf('!adf:carry{json=null}\n')), node)
|
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)
|
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', () => {
|
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', () => {
|
test('holds a carried JSON value to the nesting its position leaves', () => {
|
||||||
const nested = (levels: number): string => `${'['.repeat(levels)}${']'.repeat(levels)}`
|
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`
|
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(`!adf:carry{json="${nested(largestNesting + 2)}"}\n`)), deeper(largestNesting))
|
||||||
assert.equal(
|
assert.equal(
|
||||||
content(markdownToAdf(fence('', largestNesting + 1))),
|
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))
|
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', () => {
|
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'
|
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('!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', () => {
|
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(`_a ${carried} b_\n`)), named)
|
||||||
assert.equal(content(markdownToAdf(`**${carried}**\n`)), named)
|
assert.equal(content(markdownToAdf(`**${carried}**\n`)), named)
|
||||||
assert.equal(content(markdownToAdf(`~~a ${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="[1]"}\n')), named)
|
||||||
assert.equal(content(markdownToAdf('!adf:rule {marks="{}"}\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=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', () => {
|
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: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\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: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\n!adf:/paragraph\n')), 'unmappable-image: no ADF node carries an image inside a paragraph')
|
assert.equal(content(markdownToAdf('!adf:paragraph\n\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')
|
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- 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('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\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('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('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 })
|
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)
|
||||||
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('\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' }])
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import type { ConvertFault } from '../../result.ts'
|
|||||||
import type { Flavour } from '../plain-conventions.ts'
|
import type { Flavour } from '../plain-conventions.ts'
|
||||||
import type { LineContainer } from '../line-container.ts'
|
import type { LineContainer } from '../line-container.ts'
|
||||||
import type { LinkDefinitions } from './inline-content.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 { 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 { 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 { listBreakName, listBreakSpelling } from '../block-directive.ts'
|
|
||||||
import { mintTaskIds } from './task-ids.ts'
|
import { mintTaskIds } from './task-ids.ts'
|
||||||
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
|
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||||
import { parseBlocks } from './blocks.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> {
|
function readDocument(markdown: string, flavour: Flavour): Result<AdfDocument, ParseError> {
|
||||||
const parsed = parseBlocks(markdown)
|
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 reading: Reading = { carried: new Set(), definitions: parsed.definitions, flavour, inExpand: false, memo: new Map() }
|
||||||
const content = positioned(readBlocks(parsed.blocks, reading, [], 0), documentStart)
|
const content = positioned(readBlocks(parsed.blocks, reading, [], 0), documentStart)
|
||||||
if (!content.ok) return content
|
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)
|
if (flavour === 'plain') mintTaskIds(document, markdown, reading.carried)
|
||||||
return success(document)
|
return success(document)
|
||||||
}
|
}
|
||||||
@@ -52,6 +57,10 @@ function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErr
|
|||||||
const content: AdfNode[] = []
|
const content: AdfNode[] = []
|
||||||
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) {
|
||||||
|
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) {
|
if (block.kind === 'directive' && block.name === listBreakName) {
|
||||||
const fault = listBreakFault(block, blocks[index - 1], blocks[index + 1])
|
const fault = listBreakFault(block, blocks[index - 1], blocks[index + 1])
|
||||||
if (fault !== undefined) return positioned(faulted(fault, nodePath), block.position)
|
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()
|
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 {
|
function partsFault(): ConvertFault {
|
||||||
return unsupportedNodeShape(`${listBreakName} parts two adjacent lists of one type: this one parts something else`)
|
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> {
|
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||||
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.
|
||||||
|
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 === '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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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> {
|
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> {
|
||||||
const only = blocks.length === 1 ? blocks[0] : undefined
|
const fences: Extract<Block, { kind: 'code' }>[] = []
|
||||||
if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one code block as its body: this body is not one`, path)
|
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 attribute = nodeAttrs(node)['language']
|
||||||
const fromFence = only.language !== ''
|
const fromFence = first.language !== ''
|
||||||
const slot = languageSlot(fromFence ? only.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)
|
||||||
}
|
}
|
||||||
const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: only.language } } : node
|
const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: first.language } } : node
|
||||||
return success(withContent(spelled, only.text === '' ? [] : [{ text: only.text, type: 'text' }]))
|
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> {
|
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> {
|
function codeBlockNode(language: string, text: string, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||||
if (language === carryName) {
|
const carriedType = carryFenceType(language)
|
||||||
const carried = readCarriedBlock(text, depth)
|
if (carriedType !== undefined) {
|
||||||
|
const carried = readCarriedBlock(carriedType, text, depth)
|
||||||
if (carried.fault !== undefined) return faulted(carried.fault, path)
|
if (carried.fault !== undefined) return faulted(carried.fault, path)
|
||||||
reading.carried.add(carried.value)
|
reading.carried.add(carried.value)
|
||||||
return success(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 { adfToPlainMarkdown } from '../emit/plain-reduction.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts'
|
import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts'
|
||||||
import { toEditorNormal } from '../../adf/editor-normal.ts'
|
|
||||||
|
|
||||||
const code: AdfMark = { type: 'code' }
|
const code: AdfMark = { type: 'code' }
|
||||||
const em: AdfMark = { type: 'em' }
|
const em: AdfMark = { type: 'em' }
|
||||||
@@ -18,7 +17,7 @@ const taskTypes = ['blockTaskItem', 'taskItem', 'taskList']
|
|||||||
function read(markdown: string): readonly AdfNode[] | string {
|
function read(markdown: string): readonly AdfNode[] | string {
|
||||||
const parsed = plainMarkdownToAdf(markdown)
|
const parsed = plainMarkdownToAdf(markdown)
|
||||||
if (!parsed.ok) return parsed.error.code
|
if (!parsed.ok) return parsed.error.code
|
||||||
const blocks = toEditorNormal(parsed.value).content ?? []
|
const blocks = parsed.value.content ?? []
|
||||||
const pending = [...blocks]
|
const pending = [...blocks]
|
||||||
for (let block = pending.pop(); block !== undefined; block = pending.pop()) {
|
for (let block = pending.pop(); block !== undefined; block = pending.pop()) {
|
||||||
for (const child of block.content ?? []) pending.push(child)
|
for (const child of block.content ?? []) pending.push(child)
|
||||||
@@ -31,8 +30,8 @@ function read(markdown: string): readonly AdfNode[] | string {
|
|||||||
return blocks
|
return blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
function normal(...blocks: AdfNode[]): readonly AdfNode[] {
|
function blocks(...content: AdfNode[]): readonly AdfNode[] {
|
||||||
return toEditorNormal(document(...blocks)).content ?? []
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
function document(...content: AdfNode[]): AdfDocument {
|
function document(...content: AdfNode[]): AdfDocument {
|
||||||
@@ -57,7 +56,7 @@ function bare(type: string, ...content: AdfNode[]): AdfNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function paragraph(...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 {
|
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('> [!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'), 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.')))])
|
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('.'))),
|
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'), normal(node('expand', { title: 'See x (http://y)' }, 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'), normal(node('expand', { title: 'a bc (u) and d (v)' }, 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'), normal(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'), blocks(node('expand', { title: 'http://y or a@b.c or http://a\\b' }, paragraph())))
|
||||||
assert.deepEqual(read('> [!faq]- [a b](u)\n'), normal(node('expand', { title: 'a\nb (u)' }, 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'), normal(node('expand', { title: '@M (u) http://y' }, 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'), normal(node('expand', { title: 'Set ==x== here' }, 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.'))])
|
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), normal(node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', 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'), normal(bare('bulletList', bare('listItem', bare('expand', 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'), normal(bare('expand', node('nestedExpand', { title: 'Inner' }, 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', () => {
|
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'), 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')
|
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(
|
||||||
@@ -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)
|
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())), 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.')))])
|
assert.deepEqual(roundTripped(expand), [node('expand', { title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))])
|
||||||
const tasks = bare(
|
const tasks = bare(
|
||||||
'taskList',
|
'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}`
|
for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}`
|
||||||
assert.equal(typeof read(deep), 'object')
|
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' })))
|
||||||
|
})
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
`Bar = 9`
|
`Bar = 9`
|
||||||
|
|
||||||
`Next ID = 52`
|
`Next ID = 59`
|
||||||
|
|
||||||
| Goal | W |
|
| Goal | W |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -24,13 +24,16 @@
|
|||||||
|
|
||||||
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 8 | 0.4.0 | | **Ship a CLI.** | 3 | 7 | 7 | 6 | 9 | 10.6 |
|
||||||
|
|
||||||
## Details
|
## 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.
|
### 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
|
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
|
documents HTML as it documents markdown, and its tagline and `package.json`'s `description` regain
|
||||||
HTML.
|
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>`.
|
### 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
|
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.
|
### 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
|
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,
|
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.
|
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
|
and 302 lose their `pending` exceptions, and the spelling leaves the README's "Four CommonMark
|
||||||
spellings" bullet, which counts one fewer.
|
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.
|
### 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
|
`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
|
`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.
|
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.
|
### 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
|
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
|
dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6
|
||||||
kB.
|
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.
|
### 8. Ship a CLI.
|
||||||
|
|
||||||
The Goals and G cells are provisional: no README goal or persona covers a CLI yet. The chunk
|
The Goals and G cells are provisional: no README goal or persona covers a CLI yet. The chunk
|
||||||
|
|||||||
Reference in New Issue
Block a user