Spell every document the emitter refused for a carriage return, a null character, a code-span line start or an empty text node #160
@@ -17,6 +17,15 @@
|
||||
`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-character` and `unspellable-line-start` leave `ConvertErrorCode`, and
|
||||
`adfToMarkdown` no longer returns `unspellable-whitespace`. A carriage return in text is spelled
|
||||
` ` and a null character `!adf:text{text="\u0000"}`; a paragraph line opening with a code span
|
||||
is spelled as that code span, its backticks opening no fence; a code block holding either
|
||||
character, a code span holding a null character, a text node holding no text or holding
|
||||
`content`, and an `emoji`, `mention` or `status` whose `text` holds a line ending or a null
|
||||
character ride the carry. See `MIGRATION.md`.
|
||||
- Fix a link `title` holding a null character: it is written as `!adf:link[text]{attrs}`, where it
|
||||
read back as U+FFFD.
|
||||
- **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no
|
||||
CommonMark escape spells is written as `!adf:link[text]{attrs}`.
|
||||
- **Breaking:** some directive refusals carry `malformed-directive` where they carried
|
||||
|
||||
+10
-2
@@ -62,12 +62,20 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
|
||||
|
||||
### Error codes
|
||||
|
||||
`unspellable-link` leaves `ConvertErrorCode`: a `switch` naming it stops compiling, and the link
|
||||
it named converts.
|
||||
`unspellable-character`, `unspellable-line-start` and `unspellable-link` leave `ConvertErrorCode`:
|
||||
a `switch` naming one stops compiling. A document `adfToMarkdown` refused with one of them now
|
||||
converts; markdown the parser refused with one of them now refuses with the code the table gives.
|
||||
|
||||
| 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 carriage return in text, on emit | `unspellable-character` | spells ` ` |
|
||||
| a null character in text, on emit | `unspellable-character` | spells `!adf:text{text="\u0000"}` |
|
||||
| a carriage return or null character in a code block, or a null character in a code span, on emit | `unspellable-character` | rides the carry |
|
||||
| a paragraph line opening with a code span of three or more backticks, on emit | `unspellable-line-start` | spells the code span |
|
||||
| a block directive whose CommonMark spelling holds a carriage return, a null character or a code span opening a line, on parse | `unspellable-character`, `unspellable-line-start` | `unsupported-node-shape`; write the CommonMark spelling |
|
||||
| an `emoji`, `mention` or `status` whose `text` holds a line ending or a null character, on emit | `unspellable-whitespace`, `unspellable-character` | rides the inline carry |
|
||||
| a text node holding no text, or holding `content`, on emit | `unsupported-node-shape` | rides the carry |
|
||||
| a `codeBlock` holding other than plain text nodes, on emit | `unsupported-node-shape` | rides the block carry |
|
||||
| a leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` |
|
||||
| a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` |
|
||||
|
||||
@@ -180,6 +180,7 @@ Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.2.0`
|
||||
| `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-image` | an image sits inside other content that is not another image's description, or carries a title | give the image a paragraph of its own and drop the title |
|
||||
| `unspellable-whitespace` | an `emoji`, `mention` or `status` directive's content slot spells a newline or a carriage return — ` `, ` `, `!adf:text{text="\n"}` | replace it with a space, or write the node as `!adf:carry` — an inline directive never spans lines |
|
||||
|
||||
Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`:
|
||||
|
||||
@@ -194,11 +195,8 @@ emit refuses:
|
||||
|
||||
| Code | Fires when | What you can do |
|
||||
| --- | --- | --- |
|
||||
| `unspellable-character` | text or a code block holds a carriage return or a null character, which CommonMark rewrites wherever it sits | strip or replace the character; no escape carries it through the round-trip |
|
||||
| `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span |
|
||||
| `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines |
|
||||
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
|
||||
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the lossless flavour spells as CommonMark, or a reserved directive stands out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document | fix what the message names; `spec/flavour.md` lists every type's attributes and body |
|
||||
| `unsupported-node-shape` | parsing: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node carrying one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content` | fix what the message names; `spec/flavour.md` lists every type's attributes and body |
|
||||
|
||||
## The guarantees
|
||||
|
||||
@@ -217,9 +215,7 @@ Serves Goals 1, 3 and 4.
|
||||
`spec/flavour.md`) — and one gap: a CommonMark image fits only as its own title-less paragraph;
|
||||
mid-text and titled images are error results, save an image inside another's description, which
|
||||
flattens into the alt text. Converting back yields the library's canonical spelling, which
|
||||
round-trips byte-identically — where it converts back at all: a parse succeeding is no promise of
|
||||
that, so keep the source until the way back succeeds. ``` ` `` ` ``` reads cleanly and then
|
||||
refuses.
|
||||
round-trips byte-identically.
|
||||
- Four CommonMark spellings parse without an error and build a document the reference
|
||||
implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's
|
||||
empty link, a list continuing past a marker change stays one list against CommonMark's two, a
|
||||
@@ -228,10 +224,9 @@ Serves Goals 1, 3 and 4.
|
||||
text, which the spec requires and the reference itself breaks, nesting one `<a>` in the other.
|
||||
The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite
|
||||
holds no example of the fourth.
|
||||
- Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding
|
||||
a carriage return, or a paragraph line beginning with a code span whose backticks read back as a
|
||||
fence. Show the refusal and keep the document read-only; saving markdown you could not produce
|
||||
is the loss the round-trip exists to stop.
|
||||
- Not every document converts back: `adfToMarkdown` refuses a document nesting past 500 levels, and
|
||||
the node shapes the `unsupported-node-shape` row lists. Show the refusal and keep the document
|
||||
read-only; saving markdown you could not produce is the loss the round-trip exists to stop.
|
||||
- The pipe table narrows GFM's twice: every row opens with a pipe, so GFM's bare form is an error
|
||||
result rather than the prose it reads as, and an alignment colon in the delimiter row is an
|
||||
error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ One directory per contract kind:
|
||||
is the suite; `refusals.json` pins each refusing example to its error `code`; `exceptions.json`
|
||||
pins each known divergence by `check`, `example`, `kind` and the exact `divergence`, with a
|
||||
`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) or `pending` (a parser gap).
|
||||
|
||||
JSON is two-space indent, keys sorted, and a document read back must deep-equal the fixture's
|
||||
(`docs/decisions.md` §Equality is deep). `spec.json` is the vendored, upstream machine-readable
|
||||
|
||||
@@ -13,27 +13,6 @@
|
||||
"kind": "pending",
|
||||
"reason": "A list continuing past a marker change renders as two lists, the parser opens one."
|
||||
},
|
||||
{
|
||||
"check": "fixpoint",
|
||||
"divergence": "unspellable-line-start",
|
||||
"example": 330,
|
||||
"kind": "unspellable",
|
||||
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
|
||||
},
|
||||
{
|
||||
"check": "fixpoint",
|
||||
"divergence": "unspellable-line-start",
|
||||
"example": 331,
|
||||
"kind": "unspellable",
|
||||
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
|
||||
},
|
||||
{
|
||||
"check": "fixpoint",
|
||||
"divergence": "unspellable-line-start",
|
||||
"example": 340,
|
||||
"kind": "unspellable",
|
||||
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
|
||||
},
|
||||
{
|
||||
"check": "count",
|
||||
"divergence": "em 2/1",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "a\r\nb",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "codeBlock"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
```adf:codeBlock
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "a\r\nb",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"marks": [
|
||||
{
|
||||
"type": "code"
|
||||
}
|
||||
],
|
||||
"text": "``",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
``` `` ```
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "a\r\nb\u0000c",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"marks": [
|
||||
{
|
||||
"attrs": {
|
||||
"href": "https://example.com/",
|
||||
"title": "t\u0000"
|
||||
},
|
||||
"type": "link"
|
||||
}
|
||||
],
|
||||
"text": "link",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"attrs": {
|
||||
"color": "neutral",
|
||||
"text": "a\nb"
|
||||
},
|
||||
"type": "status"
|
||||
},
|
||||
{
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
a !adf:text{text="\n"}b!adf:text{text="\u0000"}c!adf:link[link]{href="https://example.com/" title="t\u0000"}
|
||||
|
||||
!adf:carry{json="{\"attrs\":{\"color\":\"neutral\",\"text\":\"a\\nb\"},\"type\":\"status\"}"}!adf:carry{json="{\"type\":\"text\"}"}
|
||||
+19
-20
@@ -23,13 +23,11 @@ sanitized and a mention keeps the test user's real account id.
|
||||
|
||||
## Markdown in is a canonical fixpoint
|
||||
|
||||
2026-08-23, the maintainer. Goals 1 and 4. Valid while markdown input may be written by hand.
|
||||
2026-08-23, the maintainer; every parsed document has converted back since 2026-10-04. Goals 1 and
|
||||
4. Valid while markdown input may be written by hand.
|
||||
|
||||
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
|
||||
back yields the library's canonical spelling, and that spelling round-trips byte-identically —
|
||||
where there is a way back. CommonMark spells some things the flavour has no escape for — a
|
||||
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.
|
||||
back yields the library's canonical spelling, and that spelling round-trips byte-identically.
|
||||
|
||||
## Equality is deep
|
||||
|
||||
@@ -338,8 +336,9 @@ switches on `code` with no `default`.
|
||||
`unsupported-node-shape` — since a code no input reaches is one no consumer can switch on
|
||||
(2026-09-20).
|
||||
- A refusal no spelling recovers from is a gap in the flavour rather than a code: give the flavour
|
||||
the spelling and the code goes (`unspellable-link`, 2026-09-13). A cause the carry answers gets
|
||||
no code: a mark no spelling writes rides the carry with its node.
|
||||
the spelling and the code goes (`unspellable-link`, 2026-09-13; `unspellable-character` and
|
||||
`unspellable-line-start`, 2026-10-04). A cause the carry answers gets no code: a mark no
|
||||
spelling writes rides the carry with its node.
|
||||
|
||||
## Which code a cause takes
|
||||
|
||||
@@ -348,11 +347,10 @@ handles one cause alike whichever node, attribute or direction raised it.
|
||||
|
||||
- A code names the cause; where one cause recurs across node types, across one mark's attributes
|
||||
or across directions, one code covers them all and `path` and `message` say which —
|
||||
`unsupported-nesting-depth` is the 500-level guard whichever direction hits it,
|
||||
`unspellable-character` the text node and the code block alike. Where two codes stay apart, the
|
||||
line between them is what they name: `unspellable-character` is a character CommonMark rewrites
|
||||
wherever text holds it, `unspellable-whitespace` the newline no inline directive's content slot
|
||||
spans, in either direction.
|
||||
`unsupported-nesting-depth` is the 500-level guard whichever direction hits it.
|
||||
`unspellable-whitespace` is the newline an inline directive's content slot spells in input; the
|
||||
emitter carries such a node. A null character in that slot is `unsupported-node-shape`, since
|
||||
`unspellable-whitespace` does not read true of it (2026-10-04).
|
||||
- A claim code names the spelling claimed, never the node that spelling would have built: a
|
||||
malformed `!adf:table` is a `malformed-directive`, and an alignment colon a
|
||||
`malformed-pipe-table` — the flavour's own delimiter row is `-` runs, so the grammar refuses the
|
||||
@@ -495,16 +493,17 @@ exits 0 on.
|
||||
2026-10-03, the maintainer. KISS, a technical principle, and its comprehension floor of 7. Valid
|
||||
while `todo.md` items 59, 60, 61 and 62 are open.
|
||||
|
||||
A four-seat comprehension panel scored the project under the floor of 7. Its round-5 scores
|
||||
(2026-10-03) are the baseline. Since 2026-10-04, a chunk whose panel scores any dimension lower
|
||||
A four-seat comprehension panel scored the project under the floor of 7. Its scoring run of
|
||||
2026-10-04 is the baseline. Since 2026-10-04, a chunk whose panel scores any dimension lower
|
||||
improves what the seats named and scores again, and after three such rounds asks the maintainer.
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Junior | 6 | 5 | 6 | 5 | 5.5 |
|
||||
| Mid | 6.5 | 5 | 6 | 4.5 | 5.5 |
|
||||
| Senior | 6.5 | 5.5 | 6.5 | 5.5 | 6 |
|
||||
| Architect | 6.5 | 5.5 | 6 | 6 | 6 |
|
||||
| Junior | 7 | 5.5 | 6.5 | 6 | 6 |
|
||||
| Mid | 7 | 5.5 | 6.5 | 6 | 6 |
|
||||
| Senior | 7 | 5.5 | 6.5 | 5.5 | 6 |
|
||||
| Architect | 7 | 5.5 | 6 | 6 | 6 |
|
||||
| Mean | 7.00 | 5.50 | 6.38 | 5.88 | 6.00 |
|
||||
|
||||
Three panels on near-identical code scored overall means of 5.88, 5.75 and 5.75, and a seat moves
|
||||
±0.5 between runs.
|
||||
@@ -525,8 +524,8 @@ Each example is a named error or markdown that parses and emits to itself byte f
|
||||
reference HTML's text, tags stripped and entities decoded, equals the parsed document's; and its
|
||||
elements count the marks and nodes they map to. The fixpoint alone passes a parser returning the
|
||||
empty document, the text alone one dropping every emphasis. An exception is the maintainer's to
|
||||
add, and valid CommonMark parsing to a document `adfToMarkdown` refuses where a spelling could exist
|
||||
is a bug to fix, never an exception.
|
||||
add, and valid CommonMark parsing to a document `adfToMarkdown` refuses is a bug to fix, never an
|
||||
exception.
|
||||
|
||||
## The flavour spec is read as a source
|
||||
|
||||
|
||||
+22
-17
@@ -245,7 +245,8 @@ form.
|
||||
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.
|
||||
a text node holding text free of carriage returns and null characters, and carrying no marks,
|
||||
`attrs` or `content` — rides the block carry.
|
||||
- `heading` — container, inline body. Attributes: `level` (number), `localId` (string). `level` is
|
||||
the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no
|
||||
CommonMark spelling.
|
||||
@@ -449,6 +450,8 @@ the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in
|
||||
plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
|
||||
parsing to anything but one text node carrying neither marks, attributes nor content is a named
|
||||
error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the slot.
|
||||
An `emoji`, `mention` or `status` whose `text` attribute holds a line ending or a null character
|
||||
rides the inline carry; a content slot spelling one is a named error.
|
||||
The rest take no content, `!adf:text` included; content on a node that takes none is a named error.
|
||||
|
||||
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
|
||||
@@ -471,14 +474,15 @@ The rest take no content, `!adf:text` included; content on a node that takes non
|
||||
Shipped !adf:emoji[🎉]{shortName=":tada:"} on !adf:date{timestamp=1756080000000}.
|
||||
```
|
||||
|
||||
**Whitespace CommonMark cannot hold.** A newline inside a text node, and a space or tab where
|
||||
CommonMark strips or refuses one — a block's inline content edges, either side of a line break, an
|
||||
em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled `!adf:text{text="…"}`,
|
||||
the reserved key carrying the node's text, escaped by the attribute grammar and never literal: pipe
|
||||
cells trim and pad. The emitter wraps the whitespace run alone and leaves the rest plain text, which
|
||||
the spelled run joins on reading. Input reads that spelling alone: the value is one run of spaces
|
||||
and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries
|
||||
plainly — is a named error.
|
||||
**Text CommonMark cannot hold plainly.** A carriage return in text is ` `, which CommonMark
|
||||
reads back as the character. Three things are spelled `!adf:text{text="…"}`: a newline inside a text
|
||||
node; a null character, which CommonMark reads as U+FFFD; and a space or tab where 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. The reserved key carries the node's text,
|
||||
escaped by the attribute grammar and never literal: pipe cells trim and pad. The emitter wraps the
|
||||
run alone and leaves the rest plain text, which the spelled run joins on reading. Input reads that
|
||||
spelling alone: the value is one run of spaces and tabs, one run of newlines, or one run of null
|
||||
characters, and anything else — a mixed run, or text CommonMark carries plainly — is a named error.
|
||||
|
||||
```
|
||||
!adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines.
|
||||
@@ -489,8 +493,8 @@ carried, neither holds `attrs` or an empty key, and their marks are identical, a
|
||||
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.
|
||||
§`!adf:textBreak{}` parts text CommonMark would join). A text node holding `attrs`, `content` or an
|
||||
empty key, or holding no text, rides the inline carry.
|
||||
|
||||
```
|
||||
Hello, !adf:textBreak{}world — **Hello, !adf:textBreak{}world** — `a`!adf:textBreak{}`b`
|
||||
@@ -531,12 +535,13 @@ breaks at every node the emitter carries, so no emitted carry sits inside a mark
|
||||
An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its
|
||||
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs and
|
||||
the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside
|
||||
another mark, `code` over anything but a text node or over text holding a newline, or a spelling
|
||||
CommonMark's flanking rules cannot open or close where the run sits (`un**-real**istic`), or one
|
||||
CommonMark's matching pairs elsewhere — the intra-word `*` runs together with a neighbouring `**`,
|
||||
and the multiple-of-3 rule can leave the merged run's pairing to another delimiter — rides the
|
||||
inline carry whole. An opaque carry inside a mark spelling is a named error in input: the carry
|
||||
restores its node exactly, marks included (`docs/decisions.md` §Unknown nodes ride the carry).
|
||||
another mark, `code` over anything but a text node holding text, or over text holding a line ending
|
||||
or a null character, or a spelling CommonMark's flanking rules cannot open or close where the run
|
||||
sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere — the intra-word `*` runs
|
||||
together with a neighbouring `**`, and the multiple-of-3 rule can leave the merged run's pairing to
|
||||
another delimiter — rides the inline carry whole. An opaque carry inside a mark spelling is a named
|
||||
error in input: the carry restores its node exactly, marks included (`docs/decisions.md` §Unknown
|
||||
nodes ride the carry).
|
||||
|
||||
```
|
||||
!adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed].
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ export function emptyKeys(held: { attrs?: AdfAttributes; content?: AdfNode[]; ma
|
||||
return keys
|
||||
}
|
||||
|
||||
// A format spells this node as text; anything more rides a carry, save content, which is refused.
|
||||
// No attrs, content or empty marks; a format may spell this node as text, and anything more rides a carry.
|
||||
export function isBareText(node: AdfNode): boolean {
|
||||
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
|
||||
}
|
||||
|
||||
@@ -34,11 +34,11 @@ function shownText(nodes: readonly AdfNode[]): string[] {
|
||||
return shown.filter((text) => text !== '')
|
||||
}
|
||||
|
||||
test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => {
|
||||
test('a generated document emits markdown that reads back to it', { timeout: propertyTimeout }, () => {
|
||||
fc.assert(
|
||||
fc.property(adfDocument, (document) => {
|
||||
const emitted = adfToMarkdown(document)
|
||||
if (!emitted.ok) return
|
||||
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
||||
const read = markdownToAdf(emitted.value)
|
||||
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
|
||||
assert.deepEqual(read.value, document, `reading ${JSON.stringify(emitted.value)}`)
|
||||
|
||||
@@ -13,7 +13,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus',
|
||||
const checks = ['count', 'fixpoint', 'text'] as const
|
||||
|
||||
type Check = (typeof checks)[number]
|
||||
type ExceptionKind = 'mark-model' | 'pending' | 'unspellable'
|
||||
type ExceptionKind = 'mark-model' | 'pending'
|
||||
|
||||
type SpecExample = { example: number; html: string; markdown: string; section: string }
|
||||
|
||||
@@ -30,7 +30,7 @@ function isCheck(value: unknown): value is Check {
|
||||
}
|
||||
|
||||
function isKind(value: unknown): value is ExceptionKind {
|
||||
return value === 'mark-model' || value === 'pending' || value === 'unspellable'
|
||||
return value === 'mark-model' || value === 'pending'
|
||||
}
|
||||
|
||||
function isSpecExample(value: unknown): value is SpecExample {
|
||||
@@ -77,12 +77,12 @@ test('the CommonMark spec suite is 0.31.2, vendored byte-exact', () => {
|
||||
assert.equal(digest, 'd431b29d97b6f73e69d547109cf5081578fac931e72afe95639ebe766c1b2a20')
|
||||
})
|
||||
|
||||
test('every exception is unique, names a parsing example, and files a fixpoint only as unspellable', () => {
|
||||
test('every exception is unique, names a parsing example, and files no fixpoint divergence', () => {
|
||||
assert.equal(exceptionIndex.size, exceptions.length, 'one exception repeats an example and check another holds')
|
||||
for (const entry of exceptions) {
|
||||
assert.ok(spec.some((candidate) => candidate.example === entry.example), `exception ${entry.example} names no example in the suite`)
|
||||
assert.equal(exampleToRefusal.get(entry.example), undefined, `exception ${entry.example} is on the refusal list, not an exception`)
|
||||
if (entry.check === 'fixpoint') assert.equal(entry.kind, 'unspellable', `exception ${entry.example} files a fixpoint divergence as ${entry.kind}; a fixable hole is given the spelling instead`)
|
||||
assert.notEqual(entry.check, 'fixpoint', `exception ${entry.example} files a fixpoint divergence; the emitter spells every document the parser builds`)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -418,7 +418,7 @@ function holdsDirectiveShape(document: AdfDocument): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
test('generated markdown refuses, or what it parses to refuses to emit, or its spelling reads back and spells itself', { timeout: propertyTimeout }, () => {
|
||||
test('generated markdown refuses, or its spelling reads back and spells itself', { timeout: propertyTimeout }, () => {
|
||||
const parameters = propertyRuns(gateRuns)
|
||||
let directiveShaped = 0
|
||||
let fixpoints = 0
|
||||
@@ -427,7 +427,7 @@ test('generated markdown refuses, or what it parses to refuses to emit, or its s
|
||||
const parsed = markdownToAdf(input)
|
||||
if (!parsed.ok) return
|
||||
const emitted = adfToMarkdown(parsed.value)
|
||||
if (!emitted.ok) return
|
||||
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message} — spelling ${JSON.stringify(input)}`)
|
||||
fixpoints += 1
|
||||
if (holdsDirectiveShape(parsed.value)) directiveShaped += 1
|
||||
const read = markdownToAdf(emitted.value)
|
||||
|
||||
@@ -24,7 +24,7 @@ export const propertyTimeout = 600000
|
||||
const depthIdentifier = fc.createDepthIdentifier()
|
||||
const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' }
|
||||
const flatCommonMarkShapeWeight = 4
|
||||
export const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉日ー한𠀀', '==', '[!NOTE]', '[x]', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`)
|
||||
export const markdownPieces = fc.constantFrom(...'aZ09 \t\n\r\0!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉日ー한𠀀', '==', '[!NOTE]', '[x]', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`)
|
||||
const nestingCommonMarkShapeWeight = 21
|
||||
const spelledTypes = new Set(['text', ...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes)])
|
||||
|
||||
@@ -106,7 +106,7 @@ const mark: Arbitrary<AdfMark> = occasionallyEmpty(fc.oneof(
|
||||
))
|
||||
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
|
||||
|
||||
const textNode = occasionallyEmpty(fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' })))
|
||||
const textNode = occasionallyEmpty(fc.record({ marks, text: fc.oneof({ arbitrary: text, weight: 19 }, { arbitrary: fc.constant(''), weight: 1 }) }).map((held): AdfNode => ({ ...held, type: 'text' })))
|
||||
|
||||
const backtickRunNode = fc
|
||||
.record({ marks: fc.oneof(fc.constant<AdfMark[]>([]), fc.constant<AdfMark[]>([{ type: 'code' }]), marks), text: fc.string({ maxLength: 6, minLength: 1, unit: fc.constantFrom('`', '``', ' ', 'a') }) })
|
||||
|
||||
@@ -83,10 +83,6 @@ export function backslashEscape(text: string, index: number): string | undefined
|
||||
return isAsciiPunctuation(escaped) ? escaped : undefined
|
||||
}
|
||||
|
||||
export function claimsLine(line: string, position: LinePosition): boolean {
|
||||
return escapesLineClaim(line, 0, position) || orderedListOpener.test(line)
|
||||
}
|
||||
|
||||
export function claimsPipeLine(line: string): boolean {
|
||||
return pipeClaim.test(line)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { backslashEscape, decodeTextEscapes, holdsControlCharacter } from './grammar.ts'
|
||||
import { backslashEscape, decodeTextEscapes, holdsControlCharacter, holdsNullCharacter } from './grammar.ts'
|
||||
import { holdsEntityReference } from './entity-references.ts'
|
||||
|
||||
export type LinkDefinition = { destination: string; title?: string }
|
||||
@@ -117,5 +117,5 @@ export function escapeUnbalanced(spelling: string, opener: string, closer: strin
|
||||
}
|
||||
|
||||
function spellTitle(title: string): string | undefined {
|
||||
return /[\n\r\\]/.test(title) || holdsEntityReference(title) ? undefined : ` "${title.replaceAll('"', '\\"')}"`
|
||||
return /[\n\r\\]/.test(title) || holdsNullCharacter(title) || holdsEntityReference(title) ? undefined : ` "${title.replaceAll('"', '\\"')}"`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AttributeKind, VocabularyPair, VocabularyValue } from '../adf/attribute-vocabulary.ts'
|
||||
import type { ConvertFault } from '../result.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { backslashEscape } from './commonmark/grammar.ts'
|
||||
import { backslashEscape, holdsNullCharacter } from './commonmark/grammar.ts'
|
||||
import { backtickRun, closingBacktickRun } from './commonmark/backtick-runs.ts'
|
||||
import { isJsonValue, overNested } from '../json-value.ts'
|
||||
import { largestNesting } from '../nesting.ts'
|
||||
@@ -100,10 +100,10 @@ export function readSoleStringAttribute(span: DirectiveSpan, key: string): Read<
|
||||
return { value: spelled.decoded }
|
||||
}
|
||||
|
||||
// Both directions answer alike: an inline directive never spans lines, so no content slot holds a line ending.
|
||||
export function slotLineEndingFault(type: string, text: string): ConvertFault | undefined {
|
||||
if (!/[\n\r]/.test(text)) return undefined
|
||||
return { code: 'unspellable-whitespace', message: `the ${type} content slot holds a newline no inline directive spans` }
|
||||
// An inline directive never spans lines, and no content slot holds a null character; the emitter carries such a node.
|
||||
export function slotFault(type: string, text: string): ConvertFault | undefined {
|
||||
if (/[\n\r]/.test(text)) return { code: 'unspellable-whitespace', message: `the ${type} content slot holds a newline no inline directive spans` }
|
||||
return holdsNullCharacter(text) ? unsupportedNodeShape(`the ${type} content slot holds a null character: write the node as !adf:carry`) : undefined
|
||||
}
|
||||
|
||||
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
|
||||
|
||||
@@ -35,7 +35,7 @@ test('names the node a refusal came from, and no source the emitter never read',
|
||||
const unspellable: AdfNode = { text: 'x', type: 'paragraph' }
|
||||
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' }
|
||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0])
|
||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { type: 'text' })))), ['content', 0, 'content', 1])
|
||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { text: 'x', type: 'status' })))), ['content', 0, 'content', 1])
|
||||
assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), [])
|
||||
assert.equal(position(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), undefined)
|
||||
})
|
||||
@@ -147,8 +147,16 @@ test('carries a mark the canonical spellings cannot nest', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('refuses a line whose start block parsing would claim', () => {
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '```', type: 'text' })))), 'unspellable-line-start')
|
||||
function readsBack(doc: AdfDocument): string {
|
||||
const spelled = markdown(adfToMarkdown(doc))
|
||||
assert.deepEqual(markdownToAdf(spelled), { ok: true, value: doc }, `reading ${JSON.stringify(spelled)}`)
|
||||
return spelled
|
||||
}
|
||||
|
||||
test('spells a code span opening a line, whose backticks open no fence', () => {
|
||||
const fence: AdfNode = { marks: [{ type: 'code' }], text: '``', type: 'text' }
|
||||
assert.equal(readsBack(document(paragraph(fence))), '``` `` ```\n')
|
||||
assert.equal(readsBack(document(paragraph({ text: 'a', type: 'text' }, { type: 'hardBreak' }, fence))), 'a\\\n``` `` ```\n')
|
||||
})
|
||||
|
||||
test('escapes the delimiter row a hard break leaves opening a pipe table with no leading pipe', () => {
|
||||
@@ -456,28 +464,26 @@ test('spells a list item whose marker completes a thematic break as a directive'
|
||||
)
|
||||
})
|
||||
|
||||
test('refuses the characters CommonMark rewrites', () => {
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' }))),
|
||||
'unspellable-character: a codeBlock holds no carriage return CommonMark keeps: this text holds one',
|
||||
)
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ text: 'a\rb', type: 'text' })))),
|
||||
'unspellable-character: a text node holds a carriage return CommonMark rewrites',
|
||||
)
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\u0000b', type: 'text' })))), 'unspellable-character')
|
||||
assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))), 'unspellable-character')
|
||||
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape: a text node holds text: this one has none')
|
||||
test('spells the characters CommonMark rewrites', () => {
|
||||
assert.equal(readsBack(document(paragraph({ text: 'a\r\r\nb', type: 'text' }))), 'a !adf:text{text="\\n"}b\n')
|
||||
assert.equal(readsBack(document(paragraph({ marks: [{ type: 'strong' }], text: '\u0000\u0000a\u0000', type: 'text' }))), '**!adf:text{text="\\u0000\\u0000"}a!adf:text{text="\\u0000"}**\n')
|
||||
assert.equal(readsBack(document(paragraph({ marks: [{ attrs: { href: 'u' }, type: 'link' }], text: 'a\rb\u0000', type: 'text' }))), '[a b!adf:text{text="\\u0000"}](u)\n')
|
||||
assert.equal(readsBack(document({ attrs: { level: 1 }, content: [{ text: '\r', type: 'text' }], type: 'heading' })), '# \n')
|
||||
assert.equal(readsBack(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' })), '```adf:codeBlock\n{\n "content": [\n {\n "text": "a\\rb",\n "type": "text"\n }\n ]\n}\n```\n')
|
||||
readsBack(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))
|
||||
readsBack(document(paragraph({ marks: [{ type: 'code' }], text: 'a\u0000b', type: 'text' })))
|
||||
})
|
||||
|
||||
test('refuses a text node the spelling would empty out', () => {
|
||||
test('carries a text node holding no text, or holding content', () => {
|
||||
const nested: AdfNode[] = [{ text: 'lost', type: 'text' }]
|
||||
const empty = 'unsupported-node-shape: a text node holds text: this one has none'
|
||||
const holding = 'unsupported-node-shape: a text node holds no content: this one holds some'
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ text: '', type: 'text' })))), empty)
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))), empty)
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ content: nested, text: 'x', type: 'text' })))), holding)
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ content: nested, marks: [{ type: 'code' }], text: 'x', type: 'text' })))), holding)
|
||||
assert.equal(readsBack(document(paragraph({ text: '', type: 'text' }))), '!adf:carry{json="{\\"text\\":\\"\\",\\"type\\":\\"text\\"}"}\n')
|
||||
assert.equal(readsBack(document(paragraph({ type: 'text' }, { text: 'a', type: 'text' }))), '!adf:carry{json="{\\"type\\":\\"text\\"}"}a\n')
|
||||
readsBack(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))
|
||||
readsBack(document(paragraph({ marks: [{ type: 'em' }], text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: '', type: 'text' })))
|
||||
readsBack(document(paragraph({ content: nested, text: 'x', type: 'text' })))
|
||||
readsBack(document(paragraph({ content: nested, marks: [{ type: 'code' }], text: 'x', type: 'text' })))
|
||||
readsBack(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))
|
||||
readsBack(document({ content: [{ text: 'a', type: 'text' }, { type: 'text' }], type: 'codeBlock' }))
|
||||
})
|
||||
|
||||
test('carries a mark run whose edge holds whitespace CommonMark flanking counts', () => {
|
||||
@@ -688,8 +694,12 @@ test('refuses the content and slot an inline directive has no room for', () => {
|
||||
assert.equal(refused({ text: 'x', type: 'status' }), neither('status', 'text'))
|
||||
assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'hardBreak' }), neither('hardBreak', 'content'))
|
||||
assert.equal(refused({ text: 'x', type: 'hardBreak' }), neither('hardBreak', 'text'))
|
||||
assert.equal(refused({ attrs: { text: 'a\nb' }, type: 'status' }), 'unspellable-whitespace: the status content slot holds a newline no inline directive spans')
|
||||
assert.equal(refused({ attrs: { text: 'a\u0000b' }, type: 'status' }), 'unspellable-character: a status content slot holds a null character CommonMark replaces')
|
||||
})
|
||||
|
||||
test('carries an inline node whose content slot holds a line ending or a null character', () => {
|
||||
assert.equal(readsBack(document(paragraph({ attrs: { text: 'a\nb' }, type: 'status' }))), '!adf:carry{json="{\\"attrs\\":{\\"text\\":\\"a\\\\nb\\"},\\"type\\":\\"status\\"}"}\n')
|
||||
readsBack(document(paragraph({ attrs: { text: 'a\rb' }, type: 'emoji' })))
|
||||
readsBack(document(paragraph({ attrs: { id: 'x', text: 'a\u0000b' }, type: 'mention' })))
|
||||
})
|
||||
|
||||
test('spells the directive marks around the longest run they cover', () => {
|
||||
|
||||
@@ -128,9 +128,9 @@ function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number
|
||||
if (plain !== undefined) return plain
|
||||
if (node.type === 'blockquote') return tryBlockquote(node, path, depth, writing)
|
||||
if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, writing)
|
||||
if (node.type === 'codeBlock') return tryCodeBlock(node, path)
|
||||
if (node.type === 'codeBlock') return readableText(tryCodeBlock(node))
|
||||
if (node.type === 'heading') return tryHeading(node, path, writing.flavour)
|
||||
if (node.type === 'mediaSingle') return readableText(tryImage(node, path))
|
||||
if (node.type === 'mediaSingle') return readableText(tryImage(node))
|
||||
if (node.type === 'paragraph') return tryParagraph(node, path, writing.flavour)
|
||||
if (node.type === 'rule') return readableText(tryRule(node))
|
||||
if (node.type === 'table') return readableText(tryPipeTable(node, path, writing.flavour))
|
||||
@@ -251,43 +251,36 @@ function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, wri
|
||||
return success(commonMarkText(quoted(alert ? `\\${text}` : text), inner.value.headroom))
|
||||
}
|
||||
|
||||
function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||
function tryCodeBlock(node: AdfNode): string | undefined {
|
||||
if (!holdsOnlyAttributes(node, ['language'])) return undefined
|
||||
const slot = languageSlot(nodeAttrs(node)['language'])
|
||||
if (slot.kind === 'attribute') return undefined
|
||||
const texts = fencedTexts(node, path)
|
||||
if (!texts.ok) return texts
|
||||
const [only, ...others] = texts.value ?? []
|
||||
const [only, ...others] = fencedTexts(node) ?? []
|
||||
if (only === undefined || others.length > 0) return undefined
|
||||
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', only)))
|
||||
return fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', only)
|
||||
}
|
||||
|
||||
// spec/flavour.md, The CommonMark blocks: one fence per text node, the language on each; with no fence to carry it, the attribute does.
|
||||
function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||
const 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 texts = fencedTexts(node)
|
||||
if (texts === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||
const slot: LanguageSlot = texts.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language'])
|
||||
const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language'])
|
||||
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||
if (!opener.ok) return opener
|
||||
const info = slot.kind === 'fence' ? slot.info : ''
|
||||
return success(directivePair(node, opener.value, texts.value.map((text) => fencedCodeBlock(info, text)).join('\n')))
|
||||
return success(directivePair(node, opener.value, texts.map((text) => fencedCodeBlock(info, text)).join('\n')))
|
||||
}
|
||||
|
||||
// The text each fence holds, or `undefined` where a child is no plain text node, which the carry holds instead.
|
||||
function fencedTexts(node: AdfNode, path: ConvertErrorPath): Result<string[] | undefined> {
|
||||
if (node.content === undefined) return success([''])
|
||||
if (node.content.some((child) => !isUnmarkedBareText(child))) return success(undefined)
|
||||
// The text of each fence, or `undefined` where a child holds what no fence keeps, so the block carry takes the node.
|
||||
function fencedTexts(node: AdfNode): string[] | undefined {
|
||||
if (node.content === undefined) return ['']
|
||||
const texts: string[] = []
|
||||
for (const [index, child] of node.content.entries()) {
|
||||
const childPath = [...path, 'content', index]
|
||||
if (typeof child.text !== 'string' || child.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', childPath)
|
||||
if (/\r/.test(child.text)) return failure('unspellable-character', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', childPath)
|
||||
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
|
||||
for (const child of node.content) {
|
||||
if (!isUnmarkedBareText(child) || typeof child.text !== 'string' || child.text === '' || /\r/.test(child.text) || holdsNullCharacter(child.text)) return undefined
|
||||
texts.push(child.text)
|
||||
}
|
||||
return success(texts)
|
||||
return texts
|
||||
}
|
||||
|
||||
function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { ConvertErrorPath } from '../../result.ts'
|
||||
import { holdsOnlyAttributes, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
||||
import { tryImageLine } from './inline-line.ts'
|
||||
@@ -7,10 +6,10 @@ import { tryImageLine } from './inline-line.ts'
|
||||
const centeredMediaSingle = '{"layout":"center"}'
|
||||
const imageAttributes = ['alt', 'type', 'url']
|
||||
|
||||
export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefined {
|
||||
export function tryImage(node: AdfNode): string | undefined {
|
||||
const image = imageShape(node)
|
||||
if (image === undefined) return undefined
|
||||
return tryImageLine(image.alt, image.url, [...path, 'content', 0])
|
||||
return tryImageLine(image.alt, image.url)
|
||||
}
|
||||
|
||||
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
|
||||
|
||||
@@ -4,17 +4,17 @@ import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts'
|
||||
import { carriedInline } from '../opaque-carry.ts'
|
||||
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||
import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
||||
import { identicalMark, isBareText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { identicalMark, isBareText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { highlightDelimiter } from '../plain/conventions.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
|
||||
import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
|
||||
import { slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
|
||||
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
|
||||
import { spellTextDirective } from '../text-directive.ts'
|
||||
|
||||
@@ -38,7 +38,7 @@ type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } |
|
||||
|
||||
type LineFallbacks = { carried: Set<number>; flavour: Flavour; openingLinkAsDirective: boolean }
|
||||
|
||||
export type PlainLineFallback = { kind: 'claimed-line'; line: number; text: string } | { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
|
||||
export type PlainLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
|
||||
|
||||
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<string> {
|
||||
const emitted = emitLine(nodes, container, path, flavour)
|
||||
@@ -67,13 +67,12 @@ export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, f
|
||||
return emitted.value.line
|
||||
}
|
||||
|
||||
export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined {
|
||||
export function tryImageLine(alt: string | undefined, href: string): string | undefined {
|
||||
if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined
|
||||
const destination = spellDestination(href)
|
||||
if (destination === undefined) return undefined
|
||||
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
|
||||
const attempt = attemptLine([syntax('`)], 'paragraph', path, 'lossless')
|
||||
return attempt.ok ? attempt.value.line : undefined
|
||||
return attemptLine([syntax('`)], 'paragraph', 'lossless').line
|
||||
}
|
||||
|
||||
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<EmittedLine> {
|
||||
@@ -87,12 +86,11 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con
|
||||
if (!taken.ok) return taken
|
||||
continue
|
||||
}
|
||||
const attempt = attemptLine(emission.value.segments, container, path, flavour)
|
||||
if (!attempt.ok) return attempt
|
||||
if (attempt.value.line !== undefined) {
|
||||
return success({ line: attempt.value.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
|
||||
const attempt = attemptLine(emission.value.segments, container, flavour)
|
||||
if (attempt.line !== undefined) {
|
||||
return success({ line: attempt.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
|
||||
}
|
||||
const taken = takeFallback(fallbacks, attempt.value.fallback, path)
|
||||
const taken = takeFallback(fallbacks, attempt.fallback, path)
|
||||
if (!taken.ok) return taken
|
||||
}
|
||||
}
|
||||
@@ -117,12 +115,11 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path:
|
||||
return success({ segments: spellEdgeWhitespace(emission.value.segments) })
|
||||
}
|
||||
|
||||
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<LineAttempt> {
|
||||
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): LineAttempt {
|
||||
const verdict = lineVerdict(segments, container, flavour)
|
||||
if (verdict.kind === 'opening-link') return success({ fallback: 'opening-link' })
|
||||
if (verdict.kind === 'unspellable-run') return success({ fallback: verdict.runs[0] })
|
||||
if (verdict.kind === 'claimed-line') return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(verdict.text)}`, path)
|
||||
return success({ line: verdict.text })
|
||||
if (verdict.kind === 'opening-link') return { fallback: 'opening-link' }
|
||||
if (verdict.kind === 'unspellable-run') return { fallback: verdict.runs[0] }
|
||||
return { line: verdict.text }
|
||||
}
|
||||
|
||||
// The fallbacks in the order a line takes them, or the line where it takes none.
|
||||
@@ -130,10 +127,7 @@ function lineVerdict(segments: readonly InlineSegment[], container: LineContaine
|
||||
const assembled = assembleInlineLine(segments, container, flavour)
|
||||
if (assembled.openingLinkAsDirective) return { kind: 'opening-link' }
|
||||
const [run, ...others] = assembled.unspellableRuns
|
||||
if (run !== undefined) return { kind: 'unspellable-run', runs: [run, ...others] }
|
||||
const lines = assembled.line.split('\n')
|
||||
const claimed = container === 'paragraph' ? lines.findIndex((single, index) => claimsLine(single, index === 0 ? 'first' : 'later')) : -1
|
||||
return claimed === -1 ? { kind: 'line', text: assembled.line } : { kind: 'claimed-line', line: claimed, text: lines[claimed] ?? '' }
|
||||
return run === undefined ? { kind: 'line', text: assembled.line } : { kind: 'unspellable-run', runs: [run, ...others] }
|
||||
}
|
||||
|
||||
// spec/flavour.md, Inline nodes.
|
||||
@@ -223,9 +217,14 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath {
|
||||
return [...context.path, 'content', index]
|
||||
}
|
||||
|
||||
// Decides the text-node and slot-fault carries up front, so a line holding many of them is emitted once.
|
||||
function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean {
|
||||
if (carried.has(index)) return true
|
||||
return node.type !== 'text' && inlineNodeModel(node.type) === undefined
|
||||
if (node.type === 'text') return !isBareText(node) || typeof node.text !== 'string' || node.text === ''
|
||||
const model = inlineNodeModel(node.type)
|
||||
if (model === undefined) return true
|
||||
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
|
||||
return typeof slot === 'string' && slotFault(node.type, slot) !== undefined && nodeContent(node).length === 0 && node.text === undefined
|
||||
}
|
||||
|
||||
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<Emission> {
|
||||
@@ -238,7 +237,7 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
|
||||
const types = nodeMarks(node).map((mark) => mark.type)
|
||||
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
|
||||
const model = inlineNodeModel(node.type)
|
||||
if (model === undefined) return emitText(node, context, index, path)
|
||||
if (model === undefined) return success(emitText(node, context, index))
|
||||
if (node.type === 'hardBreak') return emitHardBreak(node, model, context, index, path)
|
||||
return emitInlineDirective(node, model, index, path)
|
||||
}
|
||||
@@ -259,39 +258,27 @@ function emitInlineDirective(node: AdfNode, model: InlineNodeModel, index: numbe
|
||||
if (attributes === undefined) return success({ carry: { first: index, last: index } })
|
||||
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
|
||||
if (slot === undefined) return success({ segments: [syntax(spellInlineLeafDirective(node.type, attributes))] })
|
||||
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
|
||||
const spans = slotLineEndingFault(node.type, slot)
|
||||
if (spans !== undefined) return faulted(spans, path)
|
||||
if (holdsNullCharacter(slot)) return failure('unspellable-character', `a ${node.type} content slot holds a null character CommonMark replaces`, path)
|
||||
if (typeof slot !== 'string' || slotFault(node.type, slot) !== undefined) return success({ carry: { first: index, last: index } })
|
||||
const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }]
|
||||
return success({ segments: [syntax(spellInlineDirectiveOpener(node.type)), ...content, syntax(`]${attributes}`)] })
|
||||
}
|
||||
|
||||
function textHoldingContent(node: AdfNode, path: ConvertErrorPath): Result<never> | undefined {
|
||||
return node.type === 'text' && nodeContent(node).length > 0 ? failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path) : undefined
|
||||
}
|
||||
|
||||
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
|
||||
const holding = textHoldingContent(node, path)
|
||||
if (holding !== undefined) return holding
|
||||
if (!isBareText(node)) return success({ carry: { first: index, last: index } })
|
||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
||||
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
|
||||
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
||||
// spec/flavour.md, Text CommonMark cannot hold plainly.
|
||||
function emitText(node: AdfNode, context: InlineContext, index: number): Emission {
|
||||
if (!isBareText(node) || typeof node.text !== 'string' || node.text === '') return { carry: { first: index, last: index } }
|
||||
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
|
||||
const parts = node.text.split(/(\n+)/).filter((part) => part !== '')
|
||||
return success({ segments: parts.map((part) => (part.startsWith('\n') ? textDirectiveSegment(part) : { escaping, text: part })) })
|
||||
const parts = node.text.split(/(\n+|\0+|\r)/).filter((part) => part !== '')
|
||||
return { segments: parts.map((part) => (part === '\r' ? syntax(' ') : /^[\n\0]/.test(part) ? textDirectiveSegment(part) : { escaping, text: part })) }
|
||||
}
|
||||
|
||||
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> {
|
||||
const path = nodePath(context, index)
|
||||
const range: NodeRange = { first: index, last: index + nodes.length - 1 }
|
||||
if (mark.type === 'backgroundColor' && context.flavour === 'plain') return emitHighlight(nodes, depth, range, context)
|
||||
const spelling = markSpelling(mark.type)
|
||||
if (spelling === undefined) return success({ carry: range })
|
||||
const attributes = spellMarkAttributes(mark, spelling)
|
||||
if (attributes === undefined) return success({ carry: range })
|
||||
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path)
|
||||
if (spelling.kind === 'code') return success(emitCodeSpan(nodes, depth, range))
|
||||
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
|
||||
const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined
|
||||
if (link !== undefined) return link
|
||||
@@ -325,20 +312,15 @@ 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): Emission {
|
||||
const spans: string[] = []
|
||||
for (const node of nodes) {
|
||||
const holding = textHoldingContent(node, path)
|
||||
if (holding !== undefined) return holding
|
||||
if (!isBareText(node) || nodeMarks(node).length !== depth + 1) return success({ carry: range })
|
||||
const { text } = node
|
||||
if (typeof text !== 'string' || text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
||||
if (/[\n\r]/.test(text)) return success({ carry: range })
|
||||
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
|
||||
if (!isBareText(node) || nodeMarks(node).length !== depth + 1 || typeof text !== 'string' || text === '' || /[\n\r]/.test(text) || holdsNullCharacter(text)) return { carry: range }
|
||||
const fence = '`'.repeat(longestBacktickRun(text) + 1)
|
||||
spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`)
|
||||
}
|
||||
return success({ segments: [syntax(spans.join(textBreakSpelling))] })
|
||||
return { segments: [syntax(spans.join(textBreakSpelling))] }
|
||||
}
|
||||
|
||||
function needsPadding(text: string): boolean {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { inlineMarkSpellingFault } from './directive-marks.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { readEmptyKeys, spellsEmpty } from '../empty-keys.ts'
|
||||
import { readVocabulary } from './directive-attributes.ts'
|
||||
import { slotLineEndingFault } from '../directive-syntax.ts'
|
||||
import { slotFault } from '../directive-syntax.ts'
|
||||
import { textBreakName } from '../adjacent-text.ts'
|
||||
import { textDirectiveName } from '../text-directive.ts'
|
||||
|
||||
@@ -68,8 +68,8 @@ export function readInlineDirectiveNode(
|
||||
if (text === undefined) {
|
||||
return failure('unsupported-node-shape', `the ${name} content slot holds one text node carrying neither marks, attributes nor content: this one holds something else`, path)
|
||||
}
|
||||
const spans = slotLineEndingFault(name, text)
|
||||
if (spans !== undefined) return faulted(spans, path)
|
||||
const fault = slotFault(name, text)
|
||||
if (fault !== undefined) return faulted(fault, path)
|
||||
attrs.value[slot] = text
|
||||
}
|
||||
return success(namedNode(name, attrs.value, undefined, empty.value.empty))
|
||||
|
||||
@@ -304,10 +304,8 @@ test('names the directive form a node CommonMark spells refuses', () => {
|
||||
assert.deepEqual(content(markdownToAdf('!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')), [bulletList(item({ type: 'rule' }))])
|
||||
})
|
||||
|
||||
// The spelling the emitter refuses gives the emitter's own error, never a second name for it.
|
||||
test('gives back the refusal the CommonMark spelling itself raises', () => {
|
||||
const lineStart = '!adf:blockquote\n` `` `\n!adf:/blockquote\n'
|
||||
assert.equal(content(markdownToAdf(lineStart)), 'unspellable-line-start: block parsing would claim the emitted line "``` `` ```"')
|
||||
test('refuses the directive form of a blockquote opening with a code span', () => {
|
||||
assert.equal(content(markdownToAdf('!adf:blockquote\n` `` `\n!adf:/blockquote\n')), 'unsupported-node-shape: blockquote takes the CommonMark spelling, not the directive form')
|
||||
})
|
||||
|
||||
test('names the directive name no node reads back to', () => {
|
||||
@@ -1007,9 +1005,15 @@ test('names the content slot no lone plain text node reads back from', () => {
|
||||
assert.equal(content(markdownToAdf('!adf:status[!adf:text{text="\\n"}]{color=yellow}\n')), spans)
|
||||
assert.equal(content(markdownToAdf('!adf:status[a b]{color=yellow}\n')), spans)
|
||||
assert.equal(content(markdownToAdf('!adf:status[a b]{color=yellow}\n')), spans)
|
||||
assert.equal(content(markdownToAdf('!adf:status[!adf:text{text="\\u0000"}]{color=yellow}\n')), 'unsupported-node-shape: the status content slot holds a null character: write the node as !adf:carry')
|
||||
assert.equal(content(markdownToAdf('Part !adf:mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}')
|
||||
})
|
||||
|
||||
test('reads the characters CommonMark rewrites from their spellings', () => {
|
||||
assert.deepEqual(content(markdownToAdf('a b!adf:text{text="\\u0000\\u0000"}\n')), [paragraph('a\rb\u0000\u0000')])
|
||||
assert.equal(content(markdownToAdf('!adf:text{text="\\u0000a"}\n')), 'unsupported-node-shape: text spells one run of spaces and tabs, one run of newlines, or one run of null characters: this one spells none of them')
|
||||
})
|
||||
|
||||
test('reads the whitespace the reserved text directive carries', () => {
|
||||
assert.deepEqual(content(markdownToAdf('!adf:text{text=" "}a\n')), [paragraph(' a')])
|
||||
assert.deepEqual(content(markdownToAdf('a!adf:text{text="\\n"}b\n')), [paragraph('a\nb')])
|
||||
@@ -1018,7 +1022,7 @@ test('reads the whitespace the reserved text directive carries', () => {
|
||||
})
|
||||
|
||||
test('names the text directive spelling no whitespace run reads back from', () => {
|
||||
const named = 'unsupported-node-shape: text spells one run of spaces and tabs, or one run of newlines: this one spells neither'
|
||||
const named = 'unsupported-node-shape: text spells one run of spaces and tabs, one run of newlines, or one run of null characters: this one spells none of them'
|
||||
assert.equal(content(markdownToAdf('!adf:text{text=hi}\n')), named)
|
||||
assert.equal(content(markdownToAdf('!adf:text{text=" \\n"}\n')), named)
|
||||
assert.equal(content(markdownToAdf('!adf:text{text=""}\n')), named)
|
||||
|
||||
@@ -313,12 +313,12 @@ test('breaks a line at a newline and trims whitespace at every edge CommonMark s
|
||||
assert.equal(plain(paragraph(text(' x ', code))), '` x `\n')
|
||||
assert.equal(plain(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n')
|
||||
assert.equal(plain(paragraph(text('a\r\u0000b'))), 'ab\n')
|
||||
assert.equal(plain(paragraph(text('x', link('/u', 't\r\u0000')))), '[x](/u "t")\n')
|
||||
})
|
||||
|
||||
test('drops the code mark of a span opening a line with backticks that read as a fence', () => {
|
||||
assert.equal(plain(paragraph(text('``` x', code))), '\\`\\`\\` x\n')
|
||||
assert.equal(plain(paragraph(text('a\n'), text('``` x', code))), 'a\\\n\\`\\`\\` x\n')
|
||||
assert.equal(plain(paragraph(text('a '), text('``` x', code))), 'a ```` ``` x ````\n')
|
||||
test('keeps a code span opening a line, whose backticks open no fence', () => {
|
||||
assert.equal(plain(paragraph(text('``` x', code))), '```` ``` x ````\n')
|
||||
assert.equal(plain(paragraph(text('a\n'), text('``` x', code))), 'a\\\n```` ``` x ````\n')
|
||||
})
|
||||
|
||||
test('drops an empty paragraph and merges adjacent lists of one type', () => {
|
||||
|
||||
@@ -149,7 +149,7 @@ function plainMarks(marks: readonly AdfMark[], container: LineContainer, text: s
|
||||
function plainLink(mark: AdfMark, container: LineContainer): AdfMark | undefined {
|
||||
const attrs = nodeAttrs(mark)
|
||||
const held = attrs['href']
|
||||
const title = typeof attrs['title'] === 'string' ? attrs['title'].replace(/\r/g, '').replace(/\n/g, ' ') : undefined
|
||||
const title = typeof attrs['title'] === 'string' ? attrs['title'].replace(/[\r\0]/g, '').replace(/\n/g, ' ') : undefined
|
||||
if (typeof held !== 'string') return undefined
|
||||
const href = writableHref(container === 'table-cell' ? held.replaceAll('|', '%7C') : held)
|
||||
if (title === undefined || spellLinkTarget(href, title) === undefined || (container === 'table-cell' && title.includes('|'))) return { attrs: { href }, type: 'link' }
|
||||
@@ -267,21 +267,13 @@ function spellableLine(leaves: AdfNode[], container: LineContainer, path: Conver
|
||||
|
||||
function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback): AdfNode[] | undefined {
|
||||
if (fallback.kind === 'unspellable-run') return withoutMarks(leaves, fallback.runs)
|
||||
const first = fallback.kind === 'opening-link' ? 0 : lineStart(leaves, fallback.line)
|
||||
const mark = nodeMarks(leaves[first] ?? {})[0]
|
||||
if (mark === undefined || mark.type !== (fallback.kind === 'opening-link' ? 'link' : 'code')) return undefined
|
||||
let last = first
|
||||
const mark = nodeMarks(leaves[0] ?? {})[0]
|
||||
if (mark === undefined || mark.type !== 'link') return undefined
|
||||
let last = 0
|
||||
while (sameMarkAt(nodeMarks(leaves[last + 1] ?? {}), [mark], 0)) last += 1
|
||||
// A code span is what binds the `]` a link definition reads, and dropping it keeps the link target.
|
||||
const spans = leaves.slice(first, last + 1).some((leaf) => nodeMarks(leaf).length > 1 && nodeMarks(leaf).at(-1)?.type === 'code')
|
||||
if (mark.type === 'link' && spans) return leaves.map((leaf, index) => (index < first || index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code'))))
|
||||
return withoutMarks(leaves, [{ depth: 0, first, last }])
|
||||
}
|
||||
|
||||
function lineStart(leaves: readonly AdfNode[], line: number): number {
|
||||
let index = 0
|
||||
for (let breaks = 0; breaks < line && index < leaves.length; index += 1) if (leaves[index]?.type === 'hardBreak') breaks += 1
|
||||
return index
|
||||
const spans = leaves.slice(0, last + 1).some((leaf) => nodeMarks(leaf).length > 1 && nodeMarks(leaf).at(-1)?.type === 'code')
|
||||
return spans ? leaves.map((leaf, index) => (index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code')))) : undefined
|
||||
}
|
||||
|
||||
// The runs cover disjoint leaves, so one pass drops them all.
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { DirectiveSpan, Read } from './directive-syntax.ts'
|
||||
import { readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
|
||||
|
||||
const name = 'text'
|
||||
const whitespaceRun = /^(?:[ \t]+|\n+)$/
|
||||
const spelledRun = /^(?:[ \t]+|\n+|\0+)$/
|
||||
|
||||
export const textDirectiveName = name
|
||||
|
||||
@@ -14,6 +14,6 @@ export function readTextDirective(span: DirectiveSpan): Read<string> | undefined
|
||||
if (span.name !== name) return undefined
|
||||
const spelled = readSoleStringAttribute(span, name)
|
||||
if (spelled.fault !== undefined) return spelled
|
||||
if (!whitespaceRun.test(spelled.value)) return { fault: unsupportedNodeShape(`${name} spells one run of spaces and tabs, or one run of newlines: this one spells neither`) }
|
||||
if (!spelledRun.test(spelled.value)) return { fault: unsupportedNodeShape(`${name} spells one run of spaces and tabs, one run of newlines, or one run of null characters: this one spells none of them`) }
|
||||
return spelled
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ export type ConvertErrorCode =
|
||||
| 'unknown-directive-name'
|
||||
| 'unmappable-html'
|
||||
| 'unmappable-image'
|
||||
| 'unspellable-character'
|
||||
| 'unspellable-line-start'
|
||||
| 'unspellable-whitespace'
|
||||
| 'unsupported-document-version'
|
||||
| 'unsupported-nesting-depth'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
`Bar = 9`
|
||||
|
||||
`Next ID = 67`
|
||||
`Next ID = 70`
|
||||
|
||||
| Goal | W |
|
||||
|---|---|
|
||||
@@ -25,13 +25,14 @@
|
||||
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 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 every document the emitter refuses today for a carriage return, a NUL, a code-span line start, a newline inside an emoji, mention or status, or a text node whose text is empty or missing, or which holds content.** | 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 |
|
||||
| 67 | 0.2.0 | defect | **Push an expand title's leaves one at a time, so a title carrying many text nodes returns a result.** | 1 | 1 | 4 | 9 | 1 | 24.8 |
|
||||
| 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 3 | 24.7 |
|
||||
| 64 | 0.2.0 | defect | **Read back unchanged on V8 every JSON key the emitter writes, with a fixture whose key holds `\`, `"` or a control character.** | 4 | 4 | 6 | 8 | 1, 7 | 23.0 |
|
||||
| 66 | 0.2.0 | defect | **Refuse an attribute value that is not a plain object, array or JSON primitive.** | 3 | 2 | 5 | 8 | 1 | 22.5 |
|
||||
| 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 |
|
||||
| 65 | 0.2.0 | defect | **Read an image not alone in its paragraph as its alt text linked to its URL, and a titled image alone in its paragraph as the image captioned with its title.** | 5 | 5 | 7 | 7 | 3, 4, 6 | 18.7 |
|
||||
| 68 | 0.2.0 | | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 3 | 3 | 4 | 7 | 1 | 18.2 |
|
||||
| 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`.** | 4 | 5 | 5 | 8 | 3, 4 | 17.2 |
|
||||
| 61 | 0.2.0 | decision | **Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.** | 6 | 8 | 3 | 8 | 1 | 14.0 |
|
||||
| 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 5 | 13.0 |
|
||||
@@ -50,6 +51,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 |
|
||||
| 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 |
|
||||
| 69 | 0.3.0 | question | **Decide whether `adfToPlainMarkdown` keeps a text node's `content`, and keep it or name the drop in the README.** | 2 | 2 | 3 | 5 | 6 | 7.9 |
|
||||
| 56 | 0.3.0 | principle | **Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` 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 |
|
||||
@@ -60,26 +62,7 @@
|
||||
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.
|
||||
|
||||
Lands after items 6, 59, 60, 61 and 62. The CommonMark spec suite also runs against
|
||||
`markdownToHtml`. The README documents HTML as it documents markdown, and its tagline and
|
||||
`package.json`'s `description` regain HTML.
|
||||
|
||||
### 55. Spell every document the emitter refuses today for a carriage return, a NUL, a code-span line start, a newline inside an emoji, mention or status, or a text node whose text is empty or missing, or which holds content.
|
||||
|
||||
Today `inline-line.ts` refuses a carriage return or NUL in text, and a NUL in an emoji, mention or
|
||||
status (`unspellable-character`); and a paragraph opening with a code-span run
|
||||
(`unspellable-line-start`); `fencedTexts` refuses the same characters in a code block;
|
||||
`inline-line.ts` refuses a newline in an emoji, mention or status (`unspellable-whitespace`);
|
||||
`inline-line.ts` and `fencedTexts` refuse a text node whose `text` is empty or missing, and
|
||||
`inline-line.ts` one holding `content`. In text, a carriage return is CommonMark's ` ` (Goal 4),
|
||||
and a NUL is `!adf:text{text="\u0000"}` holding just the NUL, since CommonMark reads `�` as
|
||||
U+FFFD (a writer panel, 3 of 3, 2026-10-04). The carries spell every other case: the inline carry in
|
||||
a paragraph, the block carry in a code block.
|
||||
|
||||
The maintainer approved on 2026-10-04: §Markdown in is a canonical fixpoint drops its code-span
|
||||
exception, §Which code a cause takes drops `unspellable-whitespace` as an emitter cause;
|
||||
`unspellable-character` and `unspellable-line-start` retire. `unspellable-whitespace` stays, since
|
||||
the reader raises it for a content slot spanning a newline. Found by the README-goals audit,
|
||||
2026-10-03.
|
||||
`markdownToHtml`. The README documents HTML as it documents markdown.
|
||||
|
||||
### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.
|
||||
|
||||
@@ -87,6 +70,13 @@ Goal 1 has every call return a result; the boolean guard is the one export that
|
||||
cannot say which branch refused, where `not-an-adf-document`'s message already does. Breaking:
|
||||
`MIGRATION.md` shows the guard's replacement.
|
||||
|
||||
### 67. Push an expand title's leaves one at a time, so a title carrying many text nodes returns a result.
|
||||
|
||||
`quoteNode`'s title loop (`parse/markdown-to-adf.ts`) spreads `inlineLeaves`'s result into
|
||||
`push`, against `docs/decisions.md` §Nothing spreads an unbounded array: an inline carry in a
|
||||
folded callout's title holding an unknown node with ~125k text children throws a `RangeError` from
|
||||
`plainMarkdownToAdf`. Found by the README-goals audit, 2026-10-04.
|
||||
|
||||
### 6. Specify the HTML dialect.
|
||||
|
||||
Element-by-element mapping, the `data-*` fidelity scheme, the opaque-carry form, and the documented
|
||||
@@ -130,6 +120,14 @@ its paragraph, it reads as the image with a `link` mark to `/uri`, which ADF's `
|
||||
alone in its paragraph, the image reads as its alt text linked to `/uri`, and its URL drops. A
|
||||
writer panel chose this reading 3 of 3 (2026-10-04), by Goal 5, which outranks Goal 6.
|
||||
|
||||
### 68. Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.
|
||||
|
||||
`refuseContentAndText` (`emit/inline-line.ts`) and `emitDirectiveBlock` (`emit/adf-to-markdown.ts`)
|
||||
refuse these with `unsupported-node-shape`, though `isAdfDocument` accepts them and the carry
|
||||
round-trips them; `docs/decisions.md` §The code list gives a cause the carry answers no code. Drop
|
||||
them from the emitting half of the README's `unsupported-node-shape` row. Found by the README-goals
|
||||
audit, 2026-10-04.
|
||||
|
||||
### 49. Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`.
|
||||
|
||||
Lands after item 43. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads
|
||||
@@ -145,7 +143,9 @@ which counts one fewer.
|
||||
|
||||
The comprehension panel's worst place: `escapeClaims` and `escapeClosedRuns` re-implement the
|
||||
reader's view — flanking, code-span closers, link-definition openings, highlight flanking — and
|
||||
only the property tests catch drift. It caps the panel's Locality score.
|
||||
only the property tests catch drift. It caps the panel's Locality score. The replacement runs in
|
||||
time linear in the line: today `mergesWithSyntax`, `touchesSyntax` and `closesHeading` rescan a run
|
||||
of one character from each of its characters.
|
||||
|
||||
### 52. Spell `colwidth` as a comma list, `colwidth="340,420"`.
|
||||
|
||||
@@ -249,7 +249,9 @@ measured one.
|
||||
|
||||
`adfToMarkdown` spends 23 s on one paragraph of 2000 × `un` plus `**-r**`: each run its flanking
|
||||
cannot spell re-emits the whole line before riding the carry, quadratic in the runs, and the plain
|
||||
reduction's `spellableLine` drops one mark per re-emit the same way. Make both linear.
|
||||
reduction's `spellableLine` drops one mark per re-emit the same way. An inline node whose
|
||||
attributes no spelling writes re-emits the line the same way before riding the carry. Make all
|
||||
three linear.
|
||||
|
||||
### 46. Publish the bundle size in the README, failing the release pipeline when it drifts.
|
||||
|
||||
@@ -261,13 +263,27 @@ or report the unminified gzip). The figure lands in README §The package beside
|
||||
dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6
|
||||
kB.
|
||||
|
||||
### 69. Decide whether `adfToPlainMarkdown` keeps a text node's `content`, and keep it or name the drop in the README.
|
||||
|
||||
`nodeLeaves` (`plain/inline-reduction.ts`) keeps only a text node's `text`, so
|
||||
`{ text: 'a', content: [{ text: 'kept', type: 'text' }] }` writes `a`, though `toEditorNormal`
|
||||
keeps that content (`plain/editor-normal.ts`) and an unknown node's children keep their text.
|
||||
Atlassian's schema allows no `content` on a text node, so no Atlassian reader saw it; an
|
||||
export/indexing tool fed a looser producer loses searchable text. Goal 6 reads two ways here: keep
|
||||
the children's text as an unknown node's, or name the drop in the README's plain section. Found by
|
||||
the product-owner review, 2026-10-04.
|
||||
|
||||
### 56. Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` 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. `inline-content.ts`'s `Scan` has the same shape: about fifteen
|
||||
functions write `pending`, `pieces`, `deactivatedBefore` and `openingSpellableLink` and return
|
||||
`void`, and `parseInlineContent` reads a flag `scanInline` leaves on it.
|
||||
`void`, and `parseInlineContent` reads a flag `scanInline` leaves on it. The same shape recurs in
|
||||
`mintTaskIds` (`plain/task-ids.ts`), which writes `attrs` on the document `readDocument` built;
|
||||
`takeFallback` (`emit/inline-line.ts`), which writes the record the next `lineSegments` pass reads;
|
||||
`writeUnpaired`, `markPairings` and `markHighlights` (`inline-content.ts`), whose order changes the
|
||||
output; and `nestIn` (`plain/adf-to-plain-markdown.ts`), which pushes into its caller's array.
|
||||
|
||||
### 57. Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user