13b - link takes the directive form where CommonMark cannot spell it, unspellable-link leaves the code list
CI / gate (push) Successful in 26s
CI / publish (push) Has been skipped

This commit is contained in:
2026-09-16 23:09:30 +02:00
parent d282470efa
commit 9fe5d198d1
50 changed files with 662 additions and 141 deletions
+12 -4
View File
@@ -51,6 +51,11 @@ Round-trip equality is a property tested over a corpus, not a claim made in pros
- 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 — plus one image gap.
- Tables: one header row plus plain inline cells → pipe table; anything richer → directive form. - Tables: one header row plus plain inline cells → pipe table; anything richer → directive form.
- Links: `[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark
spells the mark; `!adf:link[text]{attrs}` where it does not — an attribute CommonMark cannot
hold, an `href` or `title` no canonical escape spells, a paragraph opening whose CommonMark
spelling would read as a link reference definition — and a directive link CommonMark could spell
is refused (the maintainer, 2026-09-13).
- Identity-bearing nodes carry their ids in attributes; a document is only portable within its - Identity-bearing nodes carry their ids in attributes; a document is only portable within its
site — accepted. site — accepted.
- The HTML dialect mirrors this: semantic elements, stable `adf-*` classes, `data-*` for what HTML - The HTML dialect mirrors this: semantic elements, stable `adf-*` classes, `data-*` for what HTML
@@ -117,7 +122,7 @@ reuses a code where one fits; the list is complete at `0.1.0`. A code names the
cause; where one cause recurs across node types, across one mark's attributes or across cause; where one cause recurs across node types, across one mark's attributes or across
directions, one code covers them all and directions, one code covers them all and
`path` and `message` say which — `unsupported-nesting-depth` is the 500-level guard whichever `path` and `message` say which — `unsupported-nesting-depth` is the 500-level guard whichever
direction hits it, `unspellable-link` the destination and the title alike. Where two codes stay 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 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 rewrites wherever text holds it, `unspellable-whitespace` the newline no inline directive's
content slot spans, in either direction. A claim code names the spelling claimed, never the node that spelling would have built: content slot spans, in either direction. A claim code names the spelling claimed, never the node that spelling would have built:
@@ -125,7 +130,8 @@ a malformed `!adf:table` is a `malformed-directive`, and an alignment colon a `m
the flavour's own delimiter row is `-` runs, so the grammar refuses the colon rather than ADF's the flavour's own delimiter row is `-` runs, so the grammar refuses the colon rather than ADF's
missing column model doing it. A refusal no spelling recovers from is a gap in the flavour rather missing column model doing it. 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, which the freeze is the last moment than a code: give the flavour the spelling and the code goes, which the freeze is the last moment
for. A cause the carry answers gets no code: a mark no for — `unspellable-link` went at `0.2.0`, the directive link spelling the `href` and `title` it
refused and the attributes the carry held (the maintainer, 2026-09-13). A cause the carry answers gets no code: a mark no
spelling writes rides the carry with its node. A directive whose name reads back to no node is spelling writes rides the carry with its node. 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 `unknown-directive-name` rather than a claim code — the spelling is well formed, and telling that
apart from a typo is what a consumer switches on when a later MINOR gives the name meaning. A apart from a typo is what a consumer switches on when a later MINOR gives the name meaning. A
@@ -298,8 +304,10 @@ someone spells it or pins it.
directions must answer alike — whether a list marker interrupts a paragraph — is one function directions must answer alike — whether a list marker interrupts a paragraph — is one function
there too, never a copy per direction, however conservative the copy would be. Where the rule is there too, never a copy per direction, however conservative the copy would be. Where the rule is
the emitter's own choice, input consults it rather than restating it: the parser asks the emitter's own choice, input consults it rather than restating it: the parser asks
`commonMarkSpelling` which form the emitter picks, so no fixture the emitter writes can be `commonMarkSpelling` which form the emitter picks, and `openingLinkTakesDirective` whether the
refused, and a spelling the emitter refuses gives its own error rather than a second name for it. line a paragraph's opening link starts forces the directive link, so no fixture the emitter
writes can be refused, and a spelling the emitter refuses gives its own error rather than a
second name for it.
- The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a - The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a
format spells the narrowed value. A spelling that re-checks the type is the check's second copy. format spells the narrowed value. A spelling that re-checks the type is the check's second copy.
Reading a spelling back is the format's own: the reader sits beside the spelling it inverts, so Reading a spelling back is the format's own: the reader sits beside the spelling it inverts, so
+5
View File
@@ -42,14 +42,19 @@ function migrateMarkdown(stored: string) {
| `: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 `carry` code fence 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}` |
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 `carry` fence are claimed instead.
### Error codes ### Error codes
`unspellable-link` leaves `ConvertErrorCode`: a `switch` naming it stops compiling, and the link
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 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` |
+4 -6
View File
@@ -100,7 +100,6 @@ emit refuses:
| --- | --- | --- | | --- | --- | --- |
| `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-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-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-link` | a link `href` or `title` holds what no canonical escape spells — a backslash, a newline, a control character, an entity reference, an angle bracket beside a space | percent-encode the destination (`%5C` for the backslash, `%26` for the `&` that opens the entity), or drop the title |
| `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines | | `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines |
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | | `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take — or markdown writes as a directive a node the flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body | | `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take — or markdown writes as a directive a node the flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body |
@@ -115,7 +114,7 @@ emit refuses:
title-less paragraph; mid-text and titled images are error results. Converting back yields the title-less paragraph; mid-text and titled images are error results. Converting back yields the
library's canonical spelling, which round-trips byte-identically — where it converts back at 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. all: a parse succeeding is no promise of that, so keep the source until the way back succeeds.
`[a](/a\b)`, `<http://x?a=1&amp;b=2>` and `[a](/x&#10;y)` read cleanly and then refuse. `` ` `` ` `` reads cleanly and then refuses.
- Three CommonMark spellings parse without an error and build a document the reference renders - Three CommonMark spellings parse without an error and build a document the reference renders
differently: `[](/url)` and `[]()` stay literal text against CommonMark's empty link, a list 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, and a shortcut continuing past a marker change stays one list against CommonMark's two, and a shortcut
@@ -124,10 +123,9 @@ emit refuses:
- Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a - Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a
processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.3.0`. processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.3.0`.
- Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding - Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding
a carriage return, a link destination or title no canonical escape spells, a paragraph line a carriage return, or a paragraph line beginning with a code span whose backticks read back as a
beginning with a code span whose backticks read back as a fence. Show the refusal and keep the fence. Show the refusal and keep the document read-only; saving markdown you could not produce
document read-only; saving markdown you could not produce is the loss the round-trip exists to is the loss the round-trip exists to stop.
stop.
- The pipe table narrows GFM's twice: every row opens with a pipe, so GFM's bare form is an error - 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 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 error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in
-21
View File
@@ -1,18 +1,4 @@
[ [
{
"check": "fixpoint",
"divergence": "unspellable-link",
"example": 196,
"kind": "unspellable",
"reason": "The link title holds literal newlines no escape spells."
},
{
"check": "fixpoint",
"divergence": "unspellable-link",
"example": 202,
"kind": "unspellable",
"reason": "The link destination holds a backslash the flavour cannot escape."
},
{ {
"check": "count", "check": "count",
"divergence": "ul 2/1", "divergence": "ul 2/1",
@@ -349,13 +335,6 @@
"kind": "pending", "kind": "pending",
"reason": "An empty link text stays literal text; CommonMark renders an empty link." "reason": "An empty link text stays literal text; CommonMark renders an empty link."
}, },
{
"check": "fixpoint",
"divergence": "unspellable-link",
"example": 502,
"kind": "unspellable",
"reason": "The link destination holds a backslash the flavour cannot escape."
},
{ {
"check": "count", "check": "count",
"divergence": "a 1/5 em 1/4", "divergence": "a 1/5 em 1/4",
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1 @@
See !adf:link[the docs]{href="https://example.com/docs"} here.
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1 @@
!adf:link[the docs]{href="https://example.com/docs"}
@@ -0,0 +1,57 @@
{
"content": [
{
"content": [
{
"marks": [
{
"attrs": {
"href": "/a\\b"
},
"type": "link"
}
],
"text": "a",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"attrs": {
"href": "http://x?a=1&amp;b=2"
},
"type": "link"
}
],
"text": "http://x?a=1&amp;b=2",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"attrs": {
"href": "/x\ny"
},
"type": "link"
}
],
"text": "a",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,5 @@
[a](/a\b)
<http://x?a=1&amp;b=2>
[a](/x&#10;y)
@@ -1,3 +0,0 @@
!adf:carry{json="{\"marks\":[{\"attrs\":{\"href\":\"/u\"},\"type\":\"link\"},{\"type\":\"code\"}],\"text\":\"]: a\",\"type\":\"text\"}"}
!adf:carry{json="{\"attrs\":{\"timestamp\":\"]:a\"},\"marks\":[{\"attrs\":{\"href\":\"/u\"},\"type\":\"link\"}],\"type\":\"date\"}"}
@@ -0,0 +1,3 @@
!adf:link[`]: a`]{href="/u"}
!adf:link[!adf:date{timestamp="]:a"}]{href="/u"}
@@ -0,0 +1,29 @@
{
"content": [
{
"content": [
{
"marks": [
{
"attrs": {
"color": "#ae2e24"
},
"type": "textColor"
},
{
"attrs": {
"href": "ab:\\!adf:name{"
},
"type": "link"
}
],
"text": "ab:\\!adf:name{",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
!adf:textColor[!adf:link[ab:\\\!adf:name{]{href="ab:\\!adf:name{"}]{color="#ae2e24"}
@@ -0,0 +1,28 @@
{
"content": [
{
"content": [
{
"marks": [
{
"attrs": {
"collection": "contentId-98237",
"href": "https://example.com/docs"
},
"type": "link"
}
],
"text": "the docs",
"type": "text"
},
{
"text": " explain it.",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
!adf:link[the docs]{collection=contentId-98237 href="https://example.com/docs"} explain it.
@@ -0,0 +1,31 @@
{
"content": [
{
"content": [
{
"text": "See ",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/a b>c"
},
"type": "link"
}
],
"text": "the docs",
"type": "text"
},
{
"text": ".",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
See !adf:link[the docs]{href="https://example.com/a b>c"}.
@@ -0,0 +1,31 @@
{
"content": [
{
"content": [
{
"text": "See ",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "<https://example.com/"
},
"type": "link"
}
],
"text": "the docs",
"type": "text"
},
{
"text": ".",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
See !adf:link[the docs]{href="\u003chttps://example.com/"}.
@@ -0,0 +1,31 @@
{
"content": [
{
"content": [
{
"text": "See ",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/a\\b"
},
"type": "link"
}
],
"text": "the docs",
"type": "text"
},
{
"text": ".",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
See !adf:link[the docs]{href="https://example.com/a\\b"}.
@@ -0,0 +1,31 @@
{
"content": [
{
"content": [
{
"text": "See ",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/a\u0001b"
},
"type": "link"
}
],
"text": "the docs",
"type": "text"
},
{
"text": ".",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
See !adf:link[the docs]{href="https://example.com/a\u0001b"}.
@@ -0,0 +1,31 @@
{
"content": [
{
"content": [
{
"text": "See ",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/?a=1&amp;b=2"
},
"type": "link"
}
],
"text": "the docs",
"type": "text"
},
{
"text": ".",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
See !adf:link[the docs]{href="https://example.com/?a=1\u0026amp;b=2"}.
@@ -0,0 +1,28 @@
{
"content": [
{
"content": [
{
"marks": [
{
"attrs": {
"href": "https://example.com/docs",
"id": "01a032c3-7a90-70c9-88f6-c60f710eda07"
},
"type": "link"
}
],
"text": "the docs",
"type": "text"
},
{
"text": " explain it.",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
!adf:link[the docs]{href="https://example.com/docs" id=01a032c3-7a90-70c9-88f6-c60f710eda07} explain it.
@@ -0,0 +1,28 @@
{
"content": [
{
"content": [
{
"marks": [
{
"attrs": {
"href": "https://example.com/docs",
"occurrenceKey": "01a032c3-7a90-70c9-88f6-c60f710eda07"
},
"type": "link"
}
],
"text": "the docs",
"type": "text"
},
{
"text": " explain it.",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
!adf:link[the docs]{href="https://example.com/docs" occurrenceKey=01a032c3-7a90-70c9-88f6-c60f710eda07} explain it.
@@ -0,0 +1,32 @@
{
"content": [
{
"content": [
{
"text": "See ",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/docs",
"title": "Setup\\guide"
},
"type": "link"
}
],
"text": "the docs",
"type": "text"
},
{
"text": ".",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
See !adf:link[the docs]{href="https://example.com/docs" title="Setup\\guide"}.
@@ -0,0 +1,32 @@
{
"content": [
{
"content": [
{
"text": "See ",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/docs",
"title": "Setup &amp; guide"
},
"type": "link"
}
],
"text": "the docs",
"type": "text"
},
{
"text": ".",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
See !adf:link[the docs]{href="https://example.com/docs" title="Setup \u0026amp; guide"}.
@@ -0,0 +1,32 @@
{
"content": [
{
"content": [
{
"text": "See ",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/docs",
"title": "Setup\nguide"
},
"type": "link"
}
],
"text": "the docs",
"type": "text"
},
{
"text": ".",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
See !adf:link[the docs]{href="https://example.com/docs" title="Setup\nguide"}.
+16 -8
View File
@@ -465,13 +465,20 @@ is a named error.
## 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
`marks` key. `code`, `em`, `link`, `strike` and `strong` keep their markdown spellings, and are `marks` key. `code`, `em`, `strike` and `strong` keep their markdown spellings, and are not
not directive names: `!adf:em[x]` is a named error. `border`, `subsup`, `textColor` and `underline` directive names: `!adf:em[x]` is a named error. `border`, `subsup`, `textColor` and `underline`
are inline directives, content required non-empty. are inline directives, content required non-empty. `link` keeps its markdown spelling wherever
CommonMark holds it — `<url>` for a bare autolink-shaped text, else `[text](url "title")` — and is
the inline directive `!adf:link[text]{attrs}` only where CommonMark does not: an attribute besides
`href` and `title`, an `href` or `title` no canonical escape spells (a control character, a
backslash, an entity reference, an angle bracket beside a space or opening a bare destination, a
newline in the title), or a link opening a paragraph whose markdown spelling would read as a link
reference definition. A directive link CommonMark could spell is a named error.
- `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3). - `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3).
- `code`, `em`, `strike`, `strong` — Attributes: none. - `code`, `em`, `strike`, `strong` — Attributes: none.
- `link` — Attributes: `href` (string), `title` (string). - `link` — Attributes: `collection` (string), `href` (string), `id` (string), `occurrenceKey`
(string), `title` (string).
- `subsup` — Attributes: `type` (`sub` `sup`). - `subsup` — Attributes: `type` (`sub` `sup`).
- `textColor` — Attributes: `color` (string, `#rrggbb`). - `textColor` — Attributes: `color` (string, `#rrggbb`).
- `underline` — Attributes: none. - `underline` — Attributes: none.
@@ -490,13 +497,14 @@ and the mark lacks, an order putting a code span outside another mark, `code` ov
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
merged run's pairing to another delimiter — rides the inline carry whole. A value the spelling merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a
holds but CommonMark cannot write — a link destination or title — is a named error instead. An mark spelling is a named error in input: the carry restores its node exactly, marks included
opaque carry inside a mark spelling is a named error in input: the carry restores its node (AGENTS.md §3).
exactly, marks included (AGENTS.md §3).
``` ```
!adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed]. !adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed].
!adf:link[the release]{collection=contentId-98237 href="https://example.com/release notes"}
!adf:border[!adf:mediaInline{collection=contentId-98237 id=01a032c3-7a90-70c9-88f6-c60f710eda07}]{color="#091e42" size=2} !adf:border[!adf:mediaInline{collection=contentId-98237 id=01a032c3-7a90-70c9-88f6-c60f710eda07}]{color="#091e42" size=2}
``` ```
+1 -1
View File
@@ -18,7 +18,7 @@ type Spelled = [string, Map<string, AttributeKind>]
const carried = ['alignment', 'annotation', 'backgroundColor', 'blockCard', 'bodiedRule', 'breakout', 'dataConsumer', 'embedCard', 'fontSize', 'fragment', 'indentation', 'inlineExtension', 'placeholder'] const carried = ['alignment', 'annotation', 'backgroundColor', 'blockCard', 'bodiedRule', 'breakout', 'dataConsumer', 'embedCard', 'fontSize', 'fragment', 'indentation', 'inlineExtension', 'placeholder']
const definitionReference = '#/definitions/' const definitionReference = '#/definitions/'
const gaps = ['link.collection', 'link.id', 'link.occurrenceKey'] const gaps: string[] = []
const grammarOwn = ['doc', 'text'] const grammarOwn = ['doc', 'text']
const readKeywords = ['$ref', 'additionalProperties', 'allOf', 'anyOf', 'enum', 'items', 'maxItems', 'maximum', 'minItems', 'minLength', 'minimum', 'pattern', 'properties', 'required', 'type'] const readKeywords = ['$ref', 'additionalProperties', 'allOf', 'anyOf', 'enum', 'items', 'maxItems', 'maximum', 'minItems', 'minLength', 'minimum', 'pattern', 'properties', 'required', 'type']
const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'spec', 'adf-schema') const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'spec', 'adf-schema')
+1 -1
View File
@@ -4,7 +4,7 @@ export const markAttributes = {
border: { color: 'string', size: 'number' }, border: { color: 'string', size: 'number' },
code: {}, code: {},
em: {}, em: {},
link: { href: 'string', title: 'string' }, link: { collection: 'string', href: 'string', id: 'string', occurrenceKey: 'string', title: 'string' },
strike: {}, strike: {},
strong: {}, strong: {},
subsup: { type: 'string' }, subsup: { type: 'string' },
+27 -16
View File
@@ -82,15 +82,25 @@ test('spells a code block language no info string holds as an attribute', () =>
assert.equal(language('a\tb'), '!adf:codeBlock {language="a\\tb"}\n```\n```\n!adf:/codeBlock\n') assert.equal(language('a\tb'), '!adf:codeBlock {language="a\\tb"}\n```\n```\n!adf:/codeBlock\n')
}) })
test('refuses a link destination CommonMark cannot spell', () => { test('spells a link destination CommonMark cannot as the directive link', () => {
const link = (href: string): AdfDocument => document(paragraph({ marks: [{ attrs: { href }, type: 'link' }], text: 't', type: 'text' })) const link = (href: string): string => markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href }, type: 'link' }], text: 't', type: 'text' }))))
assert.equal(code(adfToMarkdown(link('https://example.com/a b>c'))), 'unspellable-link') assert.equal(link('https://example.com/a b>c'), '!adf:link[t]{href="https://example.com/a b>c"}\n')
assert.equal(code(adfToMarkdown(link('<https://example.com/'))), 'unspellable-link') assert.equal(link('<https://example.com/'), '!adf:link[t]{href="\\u003chttps://example.com/"}\n')
assert.equal(code(adfToMarkdown(link('https://example.com/a\\b'))), 'unspellable-link') assert.equal(link('https://example.com/a\\b'), '!adf:link[t]{href="https://example.com/a\\\\b"}\n')
assert.equal(code(adfToMarkdown(link('https://example.com/?a=1&amp;b=2'))), 'unspellable-link') assert.equal(link('https://example.com/?a=1&amp;b=2'), '!adf:link[t]{href="https://example.com/?a=1\\u0026amp;b=2"}\n')
assert.equal(code(adfToMarkdown(link('https://example.com/a\nb'))), 'unspellable-link') assert.equal(link('https://example.com/a\nb'), '!adf:link[t]{href="https://example.com/a\\nb"}\n')
const entity = 'https://example.com/?a=1&amp;b=2' const entity = 'https://example.com/?a=1&amp;b=2'
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: entity }, type: 'link' }], text: entity, type: 'text' })))), 'unspellable-link') const autolinkShaped = document(paragraph({ marks: [{ attrs: { href: entity }, type: 'link' }], text: entity, type: 'text' }))
assert.equal(markdown(adfToMarkdown(autolinkShaped)), '!adf:link[https://example.com/?a=1\\&amp;b=2]{href="https://example.com/?a=1\\u0026amp;b=2"}\n')
})
test('spells a link carrying an attribute CommonMark cannot as the directive link, and refuses none', () => {
const link = (attrs: Record<string, string>): string =>
markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'link' }], text: 't', type: 'text' }))))
assert.equal(link({ collection: 'c', href: 'https://example.com/' }), '!adf:link[t]{collection=c href="https://example.com/"}\n')
assert.equal(link({ href: 'https://example.com/', id: 'i' }), '!adf:link[t]{href="https://example.com/" id=i}\n')
assert.equal(link({ href: 'https://example.com/', occurrenceKey: 'k' }), '!adf:link[t]{href="https://example.com/" occurrenceKey=k}\n')
assert.equal(link({ href: 'https://example.com/', id: 'i', title: 'a\nb' }), '!adf:link[t]{href="https://example.com/" id=i title="a\\nb"}\n')
}) })
test('escapes the parenthesis a link destination leaves unbalanced, and no other', () => { test('escapes the parenthesis a link destination leaves unbalanced, and no other', () => {
@@ -102,19 +112,20 @@ test('escapes the parenthesis a link destination leaves unbalanced, and no other
assert.equal(link('https://example.com/a (b'), '[t](<https://example.com/a (b>)\n') assert.equal(link('https://example.com/a (b'), '[t](<https://example.com/a (b>)\n')
}) })
test('escapes the quote a link title holds, and refuses the rest', () => { test('escapes the quote a link title holds, and spells the rest as the directive link', () => {
const titled = (title: string): AdfDocument => const titled = (title: string): string =>
document(paragraph({ marks: [{ attrs: { href: 'https://example.com/', title }, type: 'link' }], text: 't', type: 'text' })) markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: 'https://example.com/', title }, type: 'link' }], text: 't', type: 'text' }))))
assert.equal(markdown(adfToMarkdown(titled('He said "hi"'))), '[t](https://example.com/ "He said \\"hi\\"")\n') assert.equal(titled('He said "hi"'), '[t](https://example.com/ "He said \\"hi\\"")\n')
assert.equal(code(adfToMarkdown(titled('a\nb'))), 'unspellable-link') assert.equal(titled('a\nb'), '!adf:link[t]{href="https://example.com/" title="a\\nb"}\n')
assert.equal(code(adfToMarkdown(titled('a\\b'))), 'unspellable-link') assert.equal(titled('a\\b'), '!adf:link[t]{href="https://example.com/" title="a\\\\b"}\n')
assert.equal(titled('a &amp; b'), '!adf:link[t]{href="https://example.com/" title="a \\u0026amp; b"}\n')
}) })
test('carries a link mark the link spelling cannot write', () => { test('carries a link mark the link spelling cannot write', () => {
const carried = (mark: AdfMark): string => markdown(adfToMarkdown(document(paragraph({ marks: [mark], text: 't', type: 'text' })))) const carried = (mark: AdfMark): string => markdown(adfToMarkdown(document(paragraph({ marks: [mark], text: 't', type: 'text' }))))
assert.equal( assert.equal(
carried({ attrs: { href: 'x', id: 'y' }, type: 'link' }), carried({ attrs: { href: 'x', rel: 'y' }, type: 'link' }),
'!adf:carry{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":\\"x\\",\\"id\\":\\"y\\"},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n', '!adf:carry{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":\\"x\\",\\"rel\\":\\"y\\"},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n',
) )
assert.equal(carried({ attrs: { href: 4 }, type: 'link' }), '!adf:carry{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":4},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n') assert.equal(carried({ attrs: { href: 4 }, type: 'link' }), '!adf:carry{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":4},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n')
assert.equal(carried({ type: 'link' }), '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n') assert.equal(carried({ type: 'link' }), '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n')
+51 -28
View File
@@ -2,21 +2,20 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { InlineDirective } from '../../adf/inline-directives.ts' import type { InlineDirective } from '../../adf/inline-directives.ts'
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts' import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts'
import { carriedInline } from '../opaque-carry.ts' import { carriedInline } from '../opaque-carry.ts'
import { claimsLine, holdsNullCharacter, isAutolink } from '../commonmark-grammar.ts' import { claimsLine, holdsNullCharacter } from '../commonmark-grammar.ts'
import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../link-syntax.ts' import { commonMarkLink, markSpelling, spellMarkAttributes, type CommonMarkLink } from '../mark-spellings.ts'
import { escapeUnbalanced, spellDestination } from '../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 { holdsDirectivePrefix, slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
import { holdsEntityReference } from '../entity-references.ts'
import { inlineDirective } from '../../adf/inline-directives.ts' import { inlineDirective } from '../../adf/inline-directives.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../backtick-runs.ts' import { longestBacktickRun } from '../backtick-runs.ts'
import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { sameMark } from '../../adf/editor-normal.ts' import { sameMark } from '../../adf/editor-normal.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'
type EmittedLine = { line: string; segments: InlineSegment[] } type EmittedLine = { line: string; openingLinkAsDirective: boolean; segments: InlineSegment[] }
type Emission = { carry: NodeRange; segments?: undefined } | { carry?: undefined; segments: InlineSegment[] } type Emission = { carry: NodeRange; segments?: undefined } | { carry?: undefined; segments: InlineSegment[] }
@@ -24,13 +23,17 @@ type InlineContext = {
atBlockEnd: boolean atBlockEnd: boolean
bracketed: boolean bracketed: boolean
carried: ReadonlySet<number> carried: ReadonlySet<number>
openingLinkAsDirective: boolean
path: ConvertErrorPath path: ConvertErrorPath
spansLines: boolean spansLines: boolean
} }
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode } type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
type LineAttempt = { carry: NodeRange; line?: undefined } | { carry?: undefined; line: string } type LineAttempt =
| { carry: NodeRange; line?: undefined; openingLinkAsDirective?: undefined }
| { carry?: undefined; line?: undefined; openingLinkAsDirective: true }
| { carry?: undefined; line: string; openingLinkAsDirective?: undefined }
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> { export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
const emitted = emitLine(nodes, container, path) const emitted = emitLine(nodes, container, path)
@@ -38,6 +41,13 @@ export function emitInlineLine(nodes: readonly AdfNode[], container: LineContain
return success(emitted.value.line) return success(emitted.value.line)
} }
// The parser asks which form a paragraph's opening link takes rather than restating the line rule (AGENTS.md §11).
export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<boolean> {
const emitted = emitLine(nodes, 'paragraph', path)
if (!emitted.ok) return emitted
return success(emitted.value.openingLinkAsDirective)
}
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined { export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
const emitted = emitLine(nodes, 'table-cell', path) const emitted = emitLine(nodes, 'table-cell', path)
if (!emitted.ok) return undefined if (!emitted.ok) return undefined
@@ -47,18 +57,19 @@ export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath):
export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined { export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined {
if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined
const destination = spellDestination(href, path) const destination = spellDestination(href)
if (!destination.ok) return undefined if (destination === undefined) return undefined
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }] const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
const attempt = attemptLine([syntax('!['), ...description, syntax(`](${destination.value})`)], 'paragraph', path) const attempt = attemptLine([syntax('!['), ...description, syntax(`](${destination})`)], 'paragraph', path)
return attempt.ok ? attempt.value.line : undefined return attempt.ok ? attempt.value.line : undefined
} }
// A carried node joins no run, so every pass carries at least one more node. // A carried node joins no run and the opening link turns directive once, so every pass carries at least one more node.
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<EmittedLine> { function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<EmittedLine> {
const carried = new Set<number>() const carried = new Set<number>()
let openingLinkAsDirective = false
for (;;) { for (;;) {
const emission = lineSegments(nodes, container, path, carried) const emission = lineSegments(nodes, container, path, carried, openingLinkAsDirective)
if (!emission.ok) return emission if (!emission.ok) return emission
if (emission.value.carry !== undefined) { if (emission.value.carry !== undefined) {
carryRange(carried, emission.value.carry) carryRange(carried, emission.value.carry)
@@ -66,8 +77,9 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con
} }
const attempt = attemptLine(emission.value.segments, container, path) const attempt = attemptLine(emission.value.segments, container, path)
if (!attempt.ok) return attempt if (!attempt.ok) return attempt
if (attempt.value.carry === undefined) return success({ line: attempt.value.line, segments: emission.value.segments }) if (attempt.value.line !== undefined) return success({ line: attempt.value.line, openingLinkAsDirective, segments: emission.value.segments })
carryRange(carried, attempt.value.carry) if (attempt.value.carry !== undefined) carryRange(carried, attempt.value.carry)
else openingLinkAsDirective = true
} }
} }
@@ -75,8 +87,15 @@ function carryRange(carried: Set<number>, range: NodeRange): void {
for (let index = range.first; index <= range.last; index += 1) carried.add(index) for (let index = range.first; index <= range.last; index += 1) carried.add(index)
} }
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, carried: ReadonlySet<number>): Result<Emission> { function lineSegments(
const emission = emitRun(nodes, 0, 0, { atBlockEnd: true, bracketed: false, carried, path, spansLines: container === 'paragraph' }) nodes: readonly AdfNode[],
container: LineContainer,
path: ConvertErrorPath,
carried: ReadonlySet<number>,
openingLinkAsDirective: boolean,
): Result<Emission> {
const context: InlineContext = { atBlockEnd: true, bracketed: false, carried, openingLinkAsDirective, path, spansLines: container === 'paragraph' }
const emission = emitRun(nodes, 0, 0, context)
if (!emission.ok) return emission if (!emission.ok) return emission
if (emission.value.carry !== undefined) return emission if (emission.value.carry !== undefined) return emission
return success({ segments: carryStrippedWhitespace(emission.value.segments) }) return success({ segments: carryStrippedWhitespace(emission.value.segments) })
@@ -84,6 +103,7 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path:
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> { function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> {
const assembled = assembleInlineLine(segments, container) const assembled = assembleInlineLine(segments, container)
if (assembled.openingLinkAsDirective) return success({ openingLinkAsDirective: true })
if (assembled.unspellableRun !== undefined) return success({ carry: assembled.unspellableRun }) if (assembled.unspellableRun !== undefined) return success({ carry: assembled.unspellableRun })
for (const [index, single] of assembled.line.split('\n').entries()) { for (const [index, single] of assembled.line.split('\n').entries()) {
if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) { if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) {
@@ -237,7 +257,13 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number,
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)
if (spelling.kind === 'link') return emitLink(nodes, mark, depth, range, context, path) if (spelling.kind === 'link') {
const href = nodeAttrs(mark)['href']
if (typeof href !== 'string') return success({ carry: range })
const opening = depth === 0 && index === 0 && context.openingLinkAsDirective
const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, isBareLink(nodes, href, depth), context.bracketed)
if (commonMark !== undefined) return emitLink(nodes, href, commonMark, depth, range, context)
}
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false }) const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
if (!inner.ok) return inner if (!inner.ok) return inner
if (inner.value.carry !== undefined) return inner if (inner.value.carry !== undefined) return inner
@@ -278,20 +304,17 @@ function needsPadding(text: string): boolean {
return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text) return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text)
} }
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext, path: ConvertErrorPath): Result<Emission> { function isBareLink(nodes: readonly AdfNode[], href: string, depth: number): boolean {
const href = nodeAttrs(mark)['href']
const title = nodeAttrs(mark)['title']
if (typeof href !== 'string') return success({ carry: range })
const node = nodes[0] const node = nodes[0]
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1 return nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1
const autolinkHolds = !context.bracketed || (!href.includes('`') && !holdsDirectivePrefix(href) && escapeUnbalanced(href, '[', ']') === href) }
if (bare && autolinkHolds && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] })
const target = spellLinkTarget(href, typeof title === 'string' ? title : undefined, path) function emitLink(nodes: readonly AdfNode[], href: string, commonMark: CommonMarkLink, depth: number, range: NodeRange, context: InlineContext): Result<Emission> {
if (!target.ok) return target if (commonMark.form === 'autolink') return success({ segments: [syntax(`<${href}>`)] })
const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true }) const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true })
if (!inner.ok) return inner if (!inner.ok) return inner
if (inner.value.carry !== undefined) return inner if (inner.value.carry !== undefined) return inner
const spelledTarget: InlineSegment = context.bracketed ? { escaping: 'bracketed-link-target', text: escapeUnbalanced(target.value, '[', ']') } : syntax(target.value) const target: InlineSegment = context.bracketed ? { escaping: 'bracketed-link-target', text: escapeUnbalanced(commonMark.target, '[', ']') } : syntax(commonMark.target)
return success({ segments: [{ escaping: 'none', nodes: range, text: '[' }, ...inner.value.segments, syntax(']('), spelledTarget, syntax(')')] }) return success({ segments: [{ escaping: 'none', nodes: range, text: '[' }, ...inner.value.segments, syntax(']('), target, syntax(')')] })
} }
+2 -3
View File
@@ -17,7 +17,7 @@ export type InlineSegment =
| { emphasis?: undefined; escaping: 'none'; nodes: NodeRange; text: string } | { emphasis?: undefined; escaping: 'none'; nodes: NodeRange; text: string }
| { emphasis?: undefined; escaping: InlineEscaping; nodes?: undefined; text: string } | { emphasis?: undefined; escaping: InlineEscaping; nodes?: undefined; text: string }
export type AssembledLine = { line: string; unspellableRun: NodeRange | undefined } export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRun: NodeRange | undefined }
type ScanLine = { position: LinePosition; start: number; text: string } type ScanLine = { position: LinePosition; start: number; text: string }
@@ -77,8 +77,7 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
output += scan.charAt(index) output += scan.charAt(index)
} }
if (container === 'paragraph' && opensLinkDefinition(output)) { if (container === 'paragraph' && opensLinkDefinition(output)) {
const opener = segments[0]?.nodes if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRun: undefined }
if (opener !== undefined) return { line: output, unspellableRun: opener }
return { line: `\\${output}`, unspellableRun: unspellableRun(segments, output, placements) } return { line: `\\${output}`, unspellableRun: unspellableRun(segments, output, placements) }
} }
return { line: output, unspellableRun: unspellableRun(segments, output, placements) } return { line: output, unspellableRun: unspellableRun(segments, output, placements) }
+11 -27
View File
@@ -1,5 +1,4 @@
import { backslashEscape, decodeTextEscapes, holdsControlCharacter } from './commonmark-grammar.ts' import { backslashEscape, decodeTextEscapes, holdsControlCharacter } from './commonmark-grammar.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
import { holdsEntityReference } from './entity-references.ts' import { holdsEntityReference } from './entity-references.ts'
export type LinkDefinition = { destination: string; title?: string } export type LinkDefinition = { destination: string; title?: string }
@@ -90,28 +89,17 @@ export function skipLinkWhitespace(text: string, offset: number): number {
return offset + rest.length - rest.replace(/^[ \t]*\n?[ \t]*/, '').length return offset + rest.length - rest.replace(/^[ \t]*\n?[ \t]*/, '').length
} }
export function spellDestination(href: string, path: ConvertErrorPath): Result<string> { export function spellDestination(href: string): string | undefined {
if (holdsControlCharacter(href)) return failure('unspellable-link', 'a link destination holds a control character', path) if (holdsControlCharacter(href) || href.includes('\\') || holdsEntityReference(href)) return undefined
if (href.includes('\\')) return failure('unspellable-link', 'no canonical escape spells a backslash in a link destination', path) if (href.includes(' ')) return /[<>]/.test(href) ? undefined : `<${href}>`
if (holdsEntityReference(href)) { return href.startsWith('<') ? undefined : escapeUnbalanced(href, '(', ')')
return failure('unspellable-link', 'a link destination holds an entity reference that decodes on the way back', path)
}
if (href.includes(' ')) {
if (/[<>]/.test(href)) {
return failure('unspellable-link', 'no canonical escape spells an angle bracket beside a space in a link destination', path)
}
return success(`<${href}>`)
}
if (href.startsWith('<')) return failure('unspellable-link', 'a bare link destination cannot begin with an angle bracket', path)
return success(escapeUnbalanced(href, '(', ')'))
} }
export function spellLinkTarget(href: string, title: string | undefined, path: ConvertErrorPath): Result<string> { export function spellLinkTarget(href: string, title: string | undefined): string | undefined {
const destination = spellDestination(href, path) const destination = spellDestination(href)
if (!destination.ok || title === undefined) return destination if (destination === undefined || title === undefined) return destination
const spelledTitle = spellTitle(title, path) const spelledTitle = spellTitle(title)
if (!spelledTitle.ok) return spelledTitle return spelledTitle === undefined ? undefined : `${destination === '' ? '<>' : destination}${spelledTitle}`
return success(`${destination.value === '' ? '<>' : destination.value}${spelledTitle.value}`)
} }
export function escapeUnbalanced(spelling: string, opener: string, closer: string): string { export function escapeUnbalanced(spelling: string, opener: string, closer: string): string {
@@ -128,10 +116,6 @@ export function escapeUnbalanced(spelling: string, opener: string, closer: strin
return spelled return spelled
} }
function spellTitle(title: string, path: ConvertErrorPath): Result<string> { function spellTitle(title: string): string | undefined {
if (/[\n\r\\]/.test(title)) { return /[\n\r\\]/.test(title) || holdsEntityReference(title) ? undefined : ` "${title.replaceAll('"', '\\"')}"`
return failure('unspellable-link', 'no canonical escape spells a backslash or newline in a link title', path)
}
if (holdsEntityReference(title)) return failure('unspellable-link', 'a link title holds an entity reference that decodes on the way back', path)
return success(` "${title.replaceAll('"', '\\"')}"`)
} }
+17 -2
View File
@@ -1,15 +1,20 @@
import type { AdfMark } from '../adf/document.ts' import type { AdfAttributes, AdfMark } from '../adf/document.ts'
import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { MarkType } from '../adf/mark-attributes.ts' import type { MarkType } from '../adf/mark-attributes.ts'
import { escapeUnbalanced, spellLinkTarget } from './link-syntax.ts'
import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './directive-syntax.ts'
import { holdsEntityReference } from './entity-references.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 } from '../adf/document.ts' import { nodeAttrs } from '../adf/document.ts'
import { spellAttributes, spellVocabulary } from './directive-syntax.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 }
export type MarkSpelling = Spelling & { attributes: AttributeVocabulary } export type MarkSpelling = Spelling & { attributes: AttributeVocabulary }
export type CommonMarkLink = { form: 'autolink'; target?: undefined } | { form: 'inline'; target: string }
const markSpellings: Readonly<Record<MarkType, Spelling>> = { const markSpellings: Readonly<Record<MarkType, Spelling>> = {
border: { kind: 'directive' }, border: { kind: 'directive' },
code: { kind: 'code' }, code: { kind: 'code' },
@@ -30,6 +35,16 @@ export function markSpelling(type: string): MarkSpelling | undefined {
return { attributes, kind: spelling.kind } return { attributes, kind: spelling.kind }
} }
// spec/flavour.md, Marks. `bare`: one text node reading exactly `href`, the link innermost.
export function commonMarkLink(attrs: AdfAttributes, href: string, bare: boolean, bracketed: boolean): CommonMarkLink | undefined {
if (Object.keys(attrs).some((key) => key !== 'href' && key !== 'title')) return undefined
const title = attrs['title']
const autolinkHolds = !bracketed || (!href.includes('`') && !holdsDirectivePrefix(href) && escapeUnbalanced(href, '[', ']') === href)
if (bare && autolinkHolds && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return { form: 'autolink' }
const target = spellLinkTarget(href, typeof title === 'string' ? title : undefined)
return target === undefined ? undefined : { form: 'inline', target }
}
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined { export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined {
const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, []) const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, [])
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
+1 -2
View File
@@ -28,10 +28,9 @@ function markdownForm(spelling: MarkSpelling): string | undefined {
case 'code': case 'code':
return '`x`' return '`x`'
case 'directive': case 'directive':
case 'link':
return undefined return undefined
case 'emphasis': case 'emphasis':
return `${spelling.spelling}x${spelling.spelling}` return `${spelling.spelling}x${spelling.spelling}`
case 'link':
return '[x](url)'
} }
} }
+41 -9
View File
@@ -1,15 +1,18 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { DirectiveSpan } from '../directive-syntax.ts' import type { DirectiveSpan } from '../directive-syntax.ts'
import type { EmphasisPairing } from '../emphasis-matching.ts' import type { EmphasisPairing } from '../emphasis-matching.ts'
import type { LineContainer } from '../emit/line-escaping.ts'
import type { LinkDefinition } from '../link-syntax.ts' import type { LinkDefinition } from '../link-syntax.ts'
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark-grammar.ts' import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark-grammar.ts'
import { backtickRun, closingBacktickRun } from '../backtick-runs.ts' import { backtickRun, closingBacktickRun } from '../backtick-runs.ts'
import { commonMarkLink } from '../mark-spellings.ts'
import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts' import { delimiterFlags, matchEmphasis, runLength } from '../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 { inlineDirective } from '../../adf/inline-directives.ts' import { inlineDirective } from '../../adf/inline-directives.ts'
import { mergeAdjacentText } from '../../adf/editor-normal.ts' import { mergeAdjacentText } from '../../adf/editor-normal.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts' import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.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 { readInlineDirective } from '../directive-syntax.ts' import { readInlineDirective } from '../directive-syntax.ts'
@@ -33,19 +36,29 @@ type Piece =
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number } type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number }
type Scan = { definitions: LinkDefinitions; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string } // `container` is the emitter's line container, `undefined` inside a directive's content slot; `openingSpellableLink` defers a directive link at offset 0 to `assemble`.
type Scan = {
container: LineContainer | undefined
definitions: LinkDefinitions
openingSpellableLink: boolean
path: ConvertErrorPath
pending: string
pieces: Piece[]
source: string
}
type SlotContent = { carry: boolean; nodes: AdfNode[] } type SlotContent = { carry: boolean; nodes: AdfNode[] }
const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included' const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
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 spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can'
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<InlineContent> { export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> {
return parseInline(source, definitions, path, true) return parseInline(source, definitions, path, container)
} }
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, strip: boolean): Result<InlineContent> { function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined): Result<InlineContent> {
const scan: Scan = { definitions, path, pending: '', pieces: [], source } const scan: Scan = { container, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source }
let index = 0 let index = 0
while (index < source.length) { while (index < source.length) {
switch (source.charAt(index)) { switch (source.charAt(index)) {
@@ -93,7 +106,7 @@ function parseInline(source: string, definitions: LinkDefinitions, path: Convert
index += 1 index += 1
} }
} }
flush(scan, strip) flush(scan, container !== undefined)
return assemble(scan) return assemble(scan)
} }
@@ -158,14 +171,14 @@ function readDirective(scan: Scan, index: number): Result<number> | undefined {
const directive = readInlineDirective(scan.source, index) const directive = readInlineDirective(scan.source, index)
if (directive === undefined) return undefined if (directive === undefined) return undefined
if (directive.fault !== undefined) return faulted(directive.fault, scan.path) if (directive.fault !== undefined) return faulted(directive.fault, scan.path)
const piece = directivePiece(scan, directive.value) const piece = directivePiece(scan, directive.value, index)
if (!piece.ok) return piece if (!piece.ok) return piece
flush(scan, false) flush(scan, false)
scan.pieces.push(piece.value) scan.pieces.push(piece.value)
return success(index + directive.value.length) return success(index + directive.value.length)
} }
function directivePiece(scan: Scan, span: DirectiveSpan): Result<Piece> { function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<Piece> {
const carried = readCarriedInline(span) const carried = readCarriedInline(span)
if (carried !== undefined) { if (carried !== undefined) {
if (carried.fault !== undefined) return faulted(carried.fault, scan.path) if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
@@ -183,6 +196,8 @@ function directivePiece(scan: Scan, span: DirectiveSpan): Result<Piece> {
return failure('unsupported-node-shape', `the ${span.name} mark wraps the [content] it marks: this one wraps none`, scan.path) return failure('unsupported-node-shape', `the ${span.name} mark wraps the [content] it marks: this one wraps none`, scan.path)
} }
if (slot.value.carry) return failure('unsupported-node-shape', carriedInMark, scan.path) if (slot.value.carry) return failure('unsupported-node-shape', carriedInMark, scan.path)
const refused = mark.value.type === 'link' ? refuseSpellableLink(scan, mark.value, slot.value.nodes, index) : undefined
if (refused !== undefined) return refused
return success({ kind: 'nodes', nodes: applyMark(slot.value.nodes, mark.value) }) return success({ kind: 'nodes', nodes: applyMark(slot.value.nodes, mark.value) })
} }
const node = readInlineDirectiveNode(span.name, span.attributes, slot.value?.nodes, scan.path) const node = readInlineDirectiveNode(span.name, span.attributes, slot.value?.nodes, scan.path)
@@ -190,9 +205,21 @@ function directivePiece(scan: Scan, span: DirectiveSpan): Result<Piece> {
return success({ kind: 'nodes', nodes: [node.value] }) return success({ kind: 'nodes', nodes: [node.value] })
} }
// 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 refuseSpellableLink(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[], index: number): Result<Piece> | undefined {
const href = nodeAttrs(mark)['href']
if (typeof href !== 'string') return undefined
const only = nodes[0]
const bare = nodes.length === 1 && only !== undefined && only.type === 'text' && only.text === href && nodeMarks(only).length === 0
if (commonMarkLink(nodeAttrs(mark), href, bare, scan.container === undefined) === undefined) return undefined
if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path)
scan.openingSpellableLink = true
return undefined
}
function slotContent(scan: Scan, content: string | undefined): Result<SlotContent | undefined> { function slotContent(scan: Scan, content: string | undefined): Result<SlotContent | undefined> {
if (content === undefined) return success(undefined) if (content === undefined) return success(undefined)
const parsed = parseInline(content, scan.definitions, scan.path, false) const parsed = parseInline(content, scan.definitions, scan.path, undefined)
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)
@@ -214,6 +241,11 @@ function assemble(scan: Scan): Result<InlineContent> {
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) const nodes = resolveNodes(scan.pieces, scan.path)
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 })
} }
+21 -3
View File
@@ -287,8 +287,8 @@ test('names the directive form a node CommonMark spells refuses', () => {
// The spelling the emitter refuses gives the emitter's own error, never a second name for it. // 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', () => { test('gives back the refusal the CommonMark spelling itself raises', () => {
const destination = '!adf:blockquote\n[t](https://example.com/a\\b)\n!adf:/blockquote\n' const lineStart = '!adf:blockquote\n` `` `\n!adf:/blockquote\n'
assert.equal(content(markdownToAdf(destination)), 'unspellable-link: no canonical escape spells a backslash in a link destination') assert.equal(content(markdownToAdf(lineStart)), 'unspellable-line-start: block parsing would claim the emitted line "``` `` ```"')
}) })
test('names the directive name no node reads back to', () => { test('names the directive name no node reads back to', () => {
@@ -966,7 +966,25 @@ test('names the mark markdown spells, never a directive', () => {
assert.equal(content(markdownToAdf('!adf:strong[a]\n')), 'unsupported-node-shape: strong is spelled **x**, never as a directive') assert.equal(content(markdownToAdf('!adf:strong[a]\n')), 'unsupported-node-shape: strong is spelled **x**, never as a directive')
assert.equal(content(markdownToAdf('!adf:strike[a]\n')), 'unsupported-node-shape: strike is spelled ~~x~~, never as a directive') assert.equal(content(markdownToAdf('!adf:strike[a]\n')), 'unsupported-node-shape: strike is spelled ~~x~~, never as a directive')
assert.equal(content(markdownToAdf('!adf:code[a]\n')), 'unsupported-node-shape: code is spelled `x`, never as a directive') assert.equal(content(markdownToAdf('!adf:code[a]\n')), 'unsupported-node-shape: code is spelled `x`, never as a directive')
assert.equal(content(markdownToAdf('!adf:link[a]{href="/u"}\n')), 'unsupported-node-shape: link is spelled [x](url), never as a directive') })
// spec/flavour.md, Marks: the directive link is refused wherever the emitter would pick the markdown spelling.
test('refuses the directive link CommonMark could spell, and reads the one it could not', () => {
const refused = 'unsupported-node-shape: link takes the directive form only where CommonMark cannot spell it: this one it can'
assert.equal(content(markdownToAdf('!adf:link[a]{href="/u"}\n')), refused)
assert.equal(content(markdownToAdf('See !adf:link[a]{href="/u"}.\n')), refused)
assert.equal(content(markdownToAdf('!adf:link[https://example.com/]{href="https://example.com/"}\n')), refused)
assert.equal(content(markdownToAdf('# !adf:link[`]: a`]{href="/u"}\n')), refused)
assert.equal(content(markdownToAdf('| !adf:link[`]: a`]{href="/u"} |\n| --- |\n')), refused)
assert.equal(content(markdownToAdf('!adf:underline[!adf:link[a]{href="/u"}]\n')), refused)
assert.deepEqual(path(markdownToAdf('Part.\n\nSee !adf:link[a]{href="/u"}.\n')), ['content', 1])
const titled: AdfNode = { marks: [{ attrs: { title: 't' }, type: 'link' }], text: 'a', type: 'text' }
assert.deepEqual(content(markdownToAdf('!adf:link[a]{title=t}\n')), [{ content: [titled], type: 'paragraph' }])
const opening: AdfNode = { marks: [{ attrs: { href: '/u' }, type: 'link' }, { type: 'code' }], text: ']: a', type: 'text' }
assert.deepEqual(content(markdownToAdf('!adf:link[`]: a`]{href="/u"}\n')), [{ content: [opening], type: 'paragraph' }])
assert.deepEqual(content(markdownToAdf('!adf:heading {level=1 localId=h}\n!adf:link[`]: a`]{href="/u"}\n!adf:/heading\n')), [
{ attrs: { level: 1, localId: 'h' }, content: [opening], type: 'heading' },
])
}) })
test('names the directive mark left without the content it wraps', () => { test('names the directive mark left without the content it wraps', () => {
+7 -6
View File
@@ -2,6 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block, DirectiveBlock } from './blocks.ts' import type { Block, DirectiveBlock } from './blocks.ts'
import type { BlockDirectiveNode } from './directive-nodes.ts' import type { BlockDirectiveNode } from './directive-nodes.ts'
import type { ConvertFault } from '../../result.ts' import type { ConvertFault } from '../../result.ts'
import type { LineContainer } from '../emit/line-escaping.ts'
import type { LinkDefinitions } from './inline-content.ts' import type { LinkDefinitions } from './inline-content.ts'
import { carryName, readCarriedBlock } from '../opaque-carry.ts' import { carryName, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling } from '../emit/adf-to-markdown.ts' import { commonMarkSpelling } from '../emit/adf-to-markdown.ts'
@@ -67,7 +68,7 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro
case 'fault': case 'fault':
return faulted(block.fault, path) return faulted(block.fault, path)
case 'heading': case 'heading':
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path) return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path, 'heading')
case 'html': case 'html':
return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path) return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path)
case 'orderedList': case 'orderedList':
@@ -119,7 +120,7 @@ function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path
const type = rowIndex === 0 ? 'tableHeader' : 'tableCell' const type = rowIndex === 0 ? 'tableHeader' : 'tableCell'
const row: AdfNode[] = [] const row: AdfNode[] = []
for (const [cellIndex, cell] of cells.entries()) { for (const [cellIndex, cell] of cells.entries()) {
const paragraph = contentNode({ type: 'paragraph' }, cell, definitions, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0]) const paragraph = contentNode({ type: 'paragraph' }, cell, definitions, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], 'table-cell')
if (!paragraph.ok) return paragraph if (!paragraph.ok) return paragraph
row.push({ content: [paragraph.value], type }) row.push({ content: [paragraph.value], type })
} }
@@ -133,7 +134,7 @@ function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: Li
const only = blocks.length === 1 ? blocks[0] : undefined const only = blocks.length === 1 ? blocks[0] : undefined
if (only?.kind === 'fault') return positioned(faulted(only.fault, path), only.position) if (only?.kind === 'fault') return positioned(faulted(only.fault, path), only.position)
if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body: this body is not one`, path) if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body: this body is not one`, path)
return positioned(contentNode(node, only.text, definitions, path), only.position) return positioned(contentNode(node, only.text, definitions, path, 'paragraph'), only.position)
} }
function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> { function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
@@ -168,14 +169,14 @@ function codeBlockNode(language: string, text: string, path: ConvertErrorPath, d
// spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs. // spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs.
function paragraphNode(text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> { function paragraphNode(text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
const content = parseInlineContent(text, definitions, path) const content = parseInlineContent(text, definitions, path, 'paragraph')
if (!content.ok) return content if (!content.ok) return content
const image = content.value.image const image = content.value.image
return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image) return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image)
} }
function contentNode(node: AdfNode, text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> { function contentNode(node: AdfNode, text: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> {
const content = parseInlineContent(text, definitions, path) const content = parseInlineContent(text, definitions, path, container)
if (!content.ok) return content if (!content.ok) return content
if (content.value.image !== undefined) return failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path) if (content.value.image !== undefined) return failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path)
return success(withContent(node, content.value.nodes)) return success(withContent(node, content.value.nodes))
-1
View File
@@ -7,7 +7,6 @@ export type ConvertErrorCode =
| 'unmappable-image' | 'unmappable-image'
| 'unspellable-character' | 'unspellable-character'
| 'unspellable-line-start' | 'unspellable-line-start'
| 'unspellable-link'
| 'unspellable-whitespace' | 'unspellable-whitespace'
| 'unsupported-document-version' | 'unsupported-document-version'
| 'unsupported-nesting-depth' | 'unsupported-nesting-depth'