mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 11:48:29 +01:00
Read every CommonMark image and empty link: an image ADF holds no node for as its linked alt text, a title as its caption, an empty link text as its destination
This commit is contained in:
@@ -57,6 +57,11 @@
|
||||
`collection`, `id` and `occurrenceKey` directly where they rode the opaque carry.
|
||||
- Fix an image inside another image's description: it flattens into the alt text, where it was
|
||||
refused.
|
||||
- **Breaking:** `unmappable-image` leaves `ConvertErrorCode`, and every image reads. One not alone
|
||||
in its paragraph reads as its alt text linked to its URL, a titled one alone in its paragraph as
|
||||
the image captioned with its title, and one that is a link's whole text as the image the link
|
||||
marks. An empty link text reads as its destination, `[](/url)` as `/url` linked, and `[]()` as
|
||||
nothing, where both stayed literal text. See `MIGRATION.md`.
|
||||
- Fix a JSON key holding an escape, in a carried node or a JSON attribute such as `parameters`:
|
||||
`losslessMarkdownToAdf` reads it as the key it spells, where on V8 13.6 and later (Node 24, Deno,
|
||||
Chrome) `markdownToAdf` could refuse markdown `adfToMarkdown` wrote, as `unsupported-node-shape`,
|
||||
|
||||
+9
-5
@@ -66,18 +66,22 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
|
||||
| --- | --- | --- |
|
||||
| a link whose text already holds one (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them |
|
||||
| markdown holding no block (`""`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former |
|
||||
| a link whose text is empty (`[](/url)`, `[]()`) | literal text | `/url` linked to `/url`, and nothing where the destination is empty too |
|
||||
| a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | in `losslessMarkdownToAdf`, the block carry, refusing a body that is not one node's canonical JSON; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block |
|
||||
|
||||
### Error codes
|
||||
|
||||
`unspellable-character`, `unspellable-line-start` and `unspellable-link` leave `ConvertErrorCode`
|
||||
and `not-a-string` joins it: a `switch` naming one of the three stops compiling, and one naming
|
||||
every code needs the new case. A document `adfToMarkdown` refused with one of the three now
|
||||
converts in `adfToLosslessMarkdown`; markdown the parser refused with one of the three now refuses
|
||||
with the code the table gives.
|
||||
`unmappable-image`, `unspellable-character`, `unspellable-line-start` and `unspellable-link` leave
|
||||
`ConvertErrorCode` and `not-a-string` joins it: a `switch` naming one of the four stops compiling,
|
||||
and one naming every code needs the new case. A document `adfToMarkdown` refused with one of the
|
||||
last three now converts in `adfToLosslessMarkdown`; markdown the parser refused with one of the
|
||||
four now reads, or refuses with the code the table gives.
|
||||
|
||||
| Input | `0.1.0` | `0.2.0` |
|
||||
| --- | --- | --- |
|
||||
| an image not alone in its paragraph (`See .`, `# `), on parse | `unmappable-image` | its alt text linked to its URL, or, beside other text inside a link, plain text the link marks |
|
||||
| a titled image alone in its paragraph (``), on parse | `unmappable-image` | the image, captioned with its title |
|
||||
| an image that is a link's whole text, alone in its paragraph (`[](/v)`), on parse | `unmappable-image` | the image, its `media` marked with the link |
|
||||
| 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"}` |
|
||||
|
||||
@@ -187,7 +187,7 @@ minor may narrow that, never widen it. For `not-a-string` it is `{ line: 1, offs
|
||||
Parsing. Every reader also raises `unsupported-nesting-depth` from the "Either direction" table,
|
||||
and:
|
||||
|
||||
- `commonMarkToAdf`: `not-a-string`, `unmappable-html` and `unmappable-image`.
|
||||
- `commonMarkToAdf`: `not-a-string` and `unmappable-html`.
|
||||
- `portableMarkdownToAdf`: those and `malformed-pipe-table`.
|
||||
- `losslessMarkdownToAdf`: every row below, and `unsupported-node-shape`.
|
||||
- `htmlToAdf`, at `0.3.0`: the rows its dialect reaches.
|
||||
@@ -199,7 +199,6 @@ and:
|
||||
| `not-a-string` | the value handed in is not a string, such as `null`, a number or a `String` object | pass the input as a string, `String(value)` for a `String` object; the message names the value's type |
|
||||
| `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.3.0` | remove the construct, or write what it holds as markdown |
|
||||
| `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 — `adfToLosslessMarkdown` and `adfToPortableMarkdown`, and `adfToHtml` at `0.3.0`:
|
||||
@@ -230,10 +229,7 @@ Serves Goals 1, 4 and 5.
|
||||
- Markdown this library reads, and markdown it writes, means what the CommonMark spec says; from
|
||||
`0.3.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below
|
||||
name every exception.
|
||||
- CommonMark is valid input to `commonMarkToAdf`, apart from the raw HTML `unmappable-html` names
|
||||
and one gap every reader shares: 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.
|
||||
- CommonMark is valid input to `commonMarkToAdf`, apart from the raw HTML `unmappable-html` names.
|
||||
- The other readers claim shapes CommonMark reads as text or code. `losslessMarkdownToAdf` claims
|
||||
four: literal text matching directive, pipe-table or strikethrough syntax, and a code fence whose
|
||||
info string opens `adf:`. `portableMarkdownToAdf` claims what [Portable
|
||||
@@ -241,14 +237,21 @@ Serves Goals 1, 4 and 5.
|
||||
without a delimiter row, is an error result; each can be kept literal (`spec/flavour.md`).
|
||||
- A document any reader built, written back with `adfToLosslessMarkdown`, takes the library's
|
||||
canonical spelling, which 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
|
||||
shortcut reference matching its definition only under Unicode case folding stays unresolved, and
|
||||
a link whose text holds an autolink keeps the inner link and leaves the outer brackets literal
|
||||
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.
|
||||
- Six CommonMark spellings parse without an error and build a document the reference
|
||||
implementation renders differently. Three because ADF holds no node for what it renders:
|
||||
- An empty link text reads as its destination, `[](/url)` as `/url` linked to `/url`, and
|
||||
`[]()` as nothing.
|
||||
- An image not alone in its paragraph reads as its alt text linked to its URL, `See .` as `See [the moon](moon.png).`; beside other text inside a link, as plain
|
||||
text the link marks.
|
||||
- An image's title reads as its caption.
|
||||
|
||||
And three more: a list continuing past a marker change stays one list against CommonMark's two,
|
||||
a shortcut reference matching its definition only under Unicode case folding stays unresolved,
|
||||
and a link whose text holds an autolink keeps the inner link and leaves the outer brackets
|
||||
literal text, which the spec requires and the reference itself breaks, nesting one `<a>` in the
|
||||
other. `corpus/commonmark-spec/exceptions.json` pins the first three `node-model` and the next
|
||||
two `pending`; the suite holds no example of the last.
|
||||
- Not every document converts back: `adfToLosslessMarkdown` 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
|
||||
|
||||
+3
-1
@@ -16,7 +16,9 @@ One directory per contract kind:
|
||||
`spec.json` 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) or `pending` (a parser gap).
|
||||
mark-per-node model), `node-model` (a permanent divergence where ADF holds no node for what
|
||||
CommonMark renders: an empty link text, an image amid text, an image's title) 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
|
||||
|
||||
@@ -286,33 +286,19 @@
|
||||
"kind": "mark-model",
|
||||
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||
},
|
||||
{
|
||||
"check": "count",
|
||||
"divergence": "a 1/0",
|
||||
"example": 484,
|
||||
"kind": "pending",
|
||||
"reason": "An empty link text stays literal text; CommonMark renders an empty link."
|
||||
},
|
||||
{
|
||||
"check": "text",
|
||||
"divergence": "\"\" against \"[](./target.md)\"",
|
||||
"divergence": "\"\" against \"./target.md\"",
|
||||
"example": 484,
|
||||
"kind": "pending",
|
||||
"reason": "An empty link text stays literal text; CommonMark renders an empty link."
|
||||
"kind": "node-model",
|
||||
"reason": "ADF holds no empty text node, so an empty link text reads as its destination, and an empty destination as nothing."
|
||||
},
|
||||
{
|
||||
"check": "count",
|
||||
"divergence": "a 1/0",
|
||||
"example": 487,
|
||||
"kind": "pending",
|
||||
"reason": "An empty link text stays literal text; CommonMark renders an empty link."
|
||||
},
|
||||
{
|
||||
"check": "text",
|
||||
"divergence": "\"\" against \"[]()\"",
|
||||
"example": 487,
|
||||
"kind": "pending",
|
||||
"reason": "An empty link text stays literal text; CommonMark renders an empty link."
|
||||
"kind": "node-model",
|
||||
"reason": "ADF holds no empty text node, so an empty link text reads as its destination, and an empty destination as nothing."
|
||||
},
|
||||
{
|
||||
"check": "count",
|
||||
@@ -377,16 +363,114 @@
|
||||
"kind": "mark-model",
|
||||
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||
},
|
||||
{
|
||||
"check": "text",
|
||||
"divergence": "\"\" against \"title\"",
|
||||
"example": 572,
|
||||
"kind": "node-model",
|
||||
"reason": "ADF's media holds no title, so the image's title reads as its caption."
|
||||
},
|
||||
{
|
||||
"check": "text",
|
||||
"divergence": "\"\" against \"train & tracks\"",
|
||||
"example": 573,
|
||||
"kind": "node-model",
|
||||
"reason": "ADF's media holds no title, so the image's title reads as its caption."
|
||||
},
|
||||
{
|
||||
"check": "text",
|
||||
"divergence": "\"\" against \"train & tracks\"",
|
||||
"example": 576,
|
||||
"kind": "node-model",
|
||||
"reason": "ADF's media holds no title, so the image's title reads as its caption."
|
||||
},
|
||||
{
|
||||
"check": "text",
|
||||
"divergence": "\"\" against \"train & tracks\"",
|
||||
"example": 577,
|
||||
"kind": "node-model",
|
||||
"reason": "ADF's media holds no title, so the image's title reads as its caption."
|
||||
},
|
||||
{
|
||||
"check": "count",
|
||||
"divergence": "em 1/2",
|
||||
"divergence": "a 0/1 img 1/0",
|
||||
"example": 579,
|
||||
"kind": "node-model",
|
||||
"reason": "ADF holds no image amid text, so the image reads as its alt text, linked to its destination."
|
||||
},
|
||||
{
|
||||
"check": "text",
|
||||
"divergence": "\"My \" against \"My foo bar\"",
|
||||
"example": 579,
|
||||
"kind": "node-model",
|
||||
"reason": "ADF holds no image amid text, so the image reads as its alt text, linked to its destination."
|
||||
},
|
||||
{
|
||||
"check": "text",
|
||||
"divergence": "\"\" against \"title\"",
|
||||
"example": 584,
|
||||
"kind": "node-model",
|
||||
"reason": "ADF's media holds no title, so the image's title reads as its caption."
|
||||
},
|
||||
{
|
||||
"check": "text",
|
||||
"divergence": "\"\" against \"title\"",
|
||||
"example": 585,
|
||||
"kind": "node-model",
|
||||
"reason": "ADF's media holds no title, so the image's title reads as its caption."
|
||||
},
|
||||
{
|
||||
"check": "text",
|
||||
"divergence": "\"\" against \"title\"",
|
||||
"example": 586,
|
||||
"kind": "node-model",
|
||||
"reason": "ADF's media holds no title, so the image's title reads as its caption."
|
||||
},
|
||||
{
|
||||
"check": "count",
|
||||
"divergence": "a 0/1 img 1/0",
|
||||
"example": 587,
|
||||
"kind": "node-model",
|
||||
"reason": "ADF holds no image amid text, so the image reads as its alt text, linked to its destination."
|
||||
},
|
||||
{
|
||||
"check": "text",
|
||||
"divergence": "\" []\" against \"foo []\"",
|
||||
"example": 587,
|
||||
"kind": "node-model",
|
||||
"reason": "ADF holds no image amid text, so the image reads as its alt text, linked to its destination."
|
||||
},
|
||||
{
|
||||
"check": "text",
|
||||
"divergence": "\"\" against \"title\"",
|
||||
"example": 588,
|
||||
"kind": "node-model",
|
||||
"reason": "ADF's media holds no title, so the image's title reads as its caption."
|
||||
},
|
||||
{
|
||||
"check": "text",
|
||||
"divergence": "\"\" against \"title\"",
|
||||
"example": 589,
|
||||
"kind": "node-model",
|
||||
"reason": "ADF's media holds no title, so the image's title reads as its caption."
|
||||
},
|
||||
{
|
||||
"check": "text",
|
||||
"divergence": "\"\" against \"title\"",
|
||||
"example": 591,
|
||||
"kind": "node-model",
|
||||
"reason": "ADF's media holds no title, so the image's title reads as its caption."
|
||||
},
|
||||
{
|
||||
"check": "count",
|
||||
"divergence": "em 1/3",
|
||||
"example": 638,
|
||||
"kind": "mark-model",
|
||||
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||
},
|
||||
{
|
||||
"check": "count",
|
||||
"divergence": "em 1/2",
|
||||
"divergence": "em 1/3",
|
||||
"example": 639,
|
||||
"kind": "mark-model",
|
||||
"reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
|
||||
|
||||
@@ -219,70 +219,14 @@
|
||||
"code": "unmappable-html",
|
||||
"example": 494
|
||||
},
|
||||
{
|
||||
"code": "unmappable-image",
|
||||
"example": 517
|
||||
},
|
||||
{
|
||||
"code": "unmappable-html",
|
||||
"example": 524
|
||||
},
|
||||
{
|
||||
"code": "unmappable-image",
|
||||
"example": 531
|
||||
},
|
||||
{
|
||||
"code": "unmappable-html",
|
||||
"example": 536
|
||||
},
|
||||
{
|
||||
"code": "unmappable-image",
|
||||
"example": 572
|
||||
},
|
||||
{
|
||||
"code": "unmappable-image",
|
||||
"example": 573
|
||||
},
|
||||
{
|
||||
"code": "unmappable-image",
|
||||
"example": 576
|
||||
},
|
||||
{
|
||||
"code": "unmappable-image",
|
||||
"example": 577
|
||||
},
|
||||
{
|
||||
"code": "unmappable-image",
|
||||
"example": 579
|
||||
},
|
||||
{
|
||||
"code": "unmappable-image",
|
||||
"example": 584
|
||||
},
|
||||
{
|
||||
"code": "unmappable-image",
|
||||
"example": 585
|
||||
},
|
||||
{
|
||||
"code": "unmappable-image",
|
||||
"example": 586
|
||||
},
|
||||
{
|
||||
"code": "unmappable-image",
|
||||
"example": 587
|
||||
},
|
||||
{
|
||||
"code": "unmappable-image",
|
||||
"example": 588
|
||||
},
|
||||
{
|
||||
"code": "unmappable-image",
|
||||
"example": 589
|
||||
},
|
||||
{
|
||||
"code": "unmappable-image",
|
||||
"example": 591
|
||||
},
|
||||
{
|
||||
"code": "unmappable-html",
|
||||
"example": 613
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
unmappable-image
|
||||
@@ -1 +0,0 @@
|
||||
See  tonight.
|
||||
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "See ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"marks": [
|
||||
{
|
||||
"attrs": {
|
||||
"href": "https://example.com/moon.png",
|
||||
"title": "Full"
|
||||
},
|
||||
"type": "link"
|
||||
}
|
||||
],
|
||||
"text": "the moon",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": " tonight, or ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"marks": [
|
||||
{
|
||||
"attrs": {
|
||||
"href": "https://example.com/sun.png"
|
||||
},
|
||||
"type": "link"
|
||||
}
|
||||
],
|
||||
"text": "https://example.com/sun.png",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": ".",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"layout": "center"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"attrs": {
|
||||
"alt": "the moon",
|
||||
"type": "external",
|
||||
"url": "https://example.com/moon.png"
|
||||
},
|
||||
"type": "media"
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "The moon, at night.",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "caption"
|
||||
}
|
||||
],
|
||||
"type": "mediaSingle"
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"layout": "center"
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"attrs": {
|
||||
"alt": "the moon",
|
||||
"type": "external",
|
||||
"url": "https://example.com/moon.png"
|
||||
},
|
||||
"marks": [
|
||||
{
|
||||
"attrs": {
|
||||
"href": "https://example.com/"
|
||||
},
|
||||
"type": "link"
|
||||
}
|
||||
],
|
||||
"type": "media"
|
||||
}
|
||||
],
|
||||
"type": "mediaSingle"
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "Look ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"marks": [
|
||||
{
|
||||
"attrs": {
|
||||
"href": "https://example.com/"
|
||||
},
|
||||
"type": "link"
|
||||
}
|
||||
],
|
||||
"text": "here the moon",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": ", ",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"marks": [
|
||||
{
|
||||
"attrs": {
|
||||
"href": "https://example.com/"
|
||||
},
|
||||
"type": "link"
|
||||
}
|
||||
],
|
||||
"text": "https://example.com/",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"text": " and .",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"level": 1
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"marks": [
|
||||
{
|
||||
"attrs": {
|
||||
"href": "https://example.com/moon.png"
|
||||
},
|
||||
"type": "link"
|
||||
}
|
||||
],
|
||||
"text": "the moon",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "heading"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
See  tonight, or .
|
||||
|
||||

|
||||
|
||||
[](https://example.com/)
|
||||
|
||||
Look [here ](https://example.com/), [](https://example.com/) and []().
|
||||
|
||||
# 
|
||||
+2
-3
@@ -180,9 +180,8 @@ opener nests by itself and leaf versus container falls out of the node's content
|
||||
|
||||
CommonMark is a subset of the lossless flavour, with carve-outs (`spec/flavour.md`): literal text
|
||||
shaped like a directive, a pipe table or a `~~` pair is claimed, and so is a code fence whose info
|
||||
string opens `adf:` (§The carry fence names the node type) — plus one image gap, which `todo.md`
|
||||
item 65 ends. `commonMarkToAdf` claims none of the carve-outs; the image gap holds there too (§Each call names
|
||||
its flavour).
|
||||
string opens `adf:` (§The carry fence names the node type). `commonMarkToAdf` claims none of the
|
||||
carve-outs (§Each call names its flavour).
|
||||
|
||||
## Tables
|
||||
|
||||
|
||||
+18
-10
@@ -5,9 +5,8 @@ The grammar of the lossless flavour, the extended markdown `adfToLosslessMarkdow
|
||||
carve-outs: literal text that matches directive syntax below or reads as a pipe table is claimed by
|
||||
the flavour, a matched `~~` pair spells `strike` (escape the `!adf:`, `|` or `~` to keep it
|
||||
literal), and a code fence whose info string opens `adf:` is the opaque carry (drop the info string
|
||||
and wrap the fence in `!adf:codeBlock {language="adf:…"}` to keep it code) — and one gap: a
|
||||
CommonMark image fits only as its own title-less paragraph — mid-text and titled images are named
|
||||
errors. `commonMarkToAdf` claims none of the four. The emitted form is contract (`docs/decisions.md`
|
||||
and wrap the fence in `!adf:codeBlock {language="adf:…"}` to keep it code). `commonMarkToAdf`
|
||||
claims none of the four. The emitted form is contract (`docs/decisions.md`
|
||||
§The formats are API). Per-node syntaxes build on this grammar in the sections below.
|
||||
|
||||
## Canonical form
|
||||
@@ -313,12 +312,19 @@ The moon, at night.
|
||||
`mediaSingle` with attrs exactly `{"layout":"center"}` holding an `external` `media` — `url` from
|
||||
the destination, `alt` the description's plain-text content when non-empty — a link or image inside
|
||||
it contributing its own text, a node spelling its text in the content slot contributing that text,
|
||||
and a break of either kind a space. `adfToLosslessMarkdown` emits the image form for exactly that
|
||||
shape — those attrs and no others, no marks on either node, no caption, and a `media` carrying
|
||||
nothing beyond `alt`, `type` and `url` — and only where CommonMark spells the pair: a destination or
|
||||
a description the image form cannot hold, an empty `alt` included, takes the directive form instead.
|
||||
An image amid other text, or one carrying a title, is a named error: `mediaInline` carries a media
|
||||
`collection` + `id`, never a URL, and no media node carries a title.
|
||||
and a break of either kind a space. A non-empty title, ``, adds a `caption`
|
||||
holding the title as one text node, and a link whose entire text is the image,
|
||||
`[](href)`, marks the `media`. `adfToLosslessMarkdown` emits the image form for exactly
|
||||
the shape with neither — those attrs and no others, no marks on either node, no caption, and a
|
||||
`media` carrying nothing beyond `alt`, `type` and `url` — and only where CommonMark spells the pair:
|
||||
a destination or a description the image form cannot hold, an empty `alt` included, takes the
|
||||
directive form instead.
|
||||
|
||||
Anywhere else — beside other content, in a heading, a table cell or an `!adf:paragraph` — an image
|
||||
reads as its alt text linked to its destination, the link titled by the image's title, since
|
||||
`mediaInline` carries a media `collection` + `id`, never a URL. An empty alt text reads as the
|
||||
destination, as an empty link text does. Inside a link's text, beside other content, the alt text
|
||||
is plain text the link marks, and the image's destination drops.
|
||||
|
||||
### Tables
|
||||
|
||||
@@ -512,7 +518,9 @@ newline in the title), or a link opening a paragraph whose markdown spelling wou
|
||||
reference definition. Every such spelling carries an `href`: a directive link CommonMark could
|
||||
spell is a named error, and so is one spelling none. No link wraps a link at any nesting, which is
|
||||
CommonMark's own rule: a `[text]` already holding one leaves the outer brackets literal text, and
|
||||
the directive form, open to no literal reading, is a named error.
|
||||
the directive form, open to no literal reading, is a named error. ADF holds no empty text node, so
|
||||
a link whose text is empty reads as its destination linked, `[](/url)` as `/url`, and one whose
|
||||
destination is empty too, `[]()`, as nothing.
|
||||
|
||||
- `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3).
|
||||
- `code`, `em`, `strike`, `strong` — Attributes: none.
|
||||
|
||||
@@ -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'
|
||||
type ExceptionKind = 'mark-model' | 'node-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'
|
||||
return value === 'mark-model' || value === 'node-model' || value === 'pending'
|
||||
}
|
||||
|
||||
function isSpecExample(value: unknown): value is SpecExample {
|
||||
@@ -91,7 +91,7 @@ test('the refusal list is unique per example and names real examples', () => {
|
||||
for (const example of exampleToRefusal.keys()) assert.ok(spec.some((entry) => entry.example === example), `refusal ${example} names no example in the suite`)
|
||||
})
|
||||
|
||||
// A mark is counted once per text node it touches (docs/decisions.md §No schema validation).
|
||||
// A mark is counted once per node it touches (docs/decisions.md §No schema validation).
|
||||
const countKeys = ['a', 'blockquote', 'br', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img', 'li', 'ol', 'pre', 'strong', 'ul']
|
||||
const nodeElement: Record<string, string> = {
|
||||
blockquote: 'blockquote',
|
||||
@@ -148,15 +148,13 @@ function nodeCounts(document: AdfNode): Record<string, number> {
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop()
|
||||
if (node === undefined) continue
|
||||
if (node.text !== undefined) {
|
||||
const seen = new Set<string>()
|
||||
for (const mark of node.marks ?? []) {
|
||||
const element = markElement[mark.type]
|
||||
if (element !== undefined) seen.add(element)
|
||||
}
|
||||
for (const element of seen) counts[element] = (counts[element] ?? 0) + 1
|
||||
continue
|
||||
const seen = new Set<string>()
|
||||
for (const mark of node.marks ?? []) {
|
||||
const element = markElement[mark.type]
|
||||
if (element !== undefined) seen.add(element)
|
||||
}
|
||||
for (const element of seen) counts[element] = (counts[element] ?? 0) + 1
|
||||
if (node.text !== undefined) continue
|
||||
if (node.type === 'heading') {
|
||||
const level = node.attrs?.['level']
|
||||
if (typeof level === 'number') counts[`h${level}`] = (counts[`h${level}`] ?? 0) + 1
|
||||
|
||||
@@ -21,7 +21,8 @@ import { readDirectiveMark } from './directive-marks.ts'
|
||||
import { readInlineDirectiveNode } from './directive-nodes.ts'
|
||||
import { readTextDirective } from '../text-directive.ts'
|
||||
|
||||
export type InlineContent = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: AdfNode[] }
|
||||
// `image` is the `mediaSingle` a lone image builds, for the one container that holds it: a plain paragraph. `nodes` reads it as its linked alt text.
|
||||
export type InlineContent = { image?: AdfNode; nodes: AdfNode[] }
|
||||
|
||||
// The text break leaf: it holds no marks, and stands between the nodes it parts until the outermost content checks and drops it.
|
||||
type TextBreak = { kind: 'textBreak' }
|
||||
@@ -31,12 +32,15 @@ type OwnMarks = { kind: 'ownMarks'; node: AdfNode }
|
||||
|
||||
type Inline = AdfNode | OwnMarks | TextBreak
|
||||
|
||||
type Scanned = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: Inline[] }
|
||||
type Scanned = { image?: AdfNode; nodes: Inline[] }
|
||||
|
||||
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
|
||||
|
||||
type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
|
||||
|
||||
// `link` is the link whose text is this image alone.
|
||||
type Image = { alt: string; definition: LinkDefinition; kind: 'image'; link?: LinkDefinition }
|
||||
|
||||
type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; opens: boolean; position: number; stretch: number }
|
||||
|
||||
type Pairing = EmphasisPairing<Run>
|
||||
@@ -44,7 +48,7 @@ type Pairing = EmphasisPairing<Run>
|
||||
type Piece =
|
||||
| Bracket
|
||||
| OwnMarks
|
||||
| { alt: string; kind: 'image'; node: AdfNode }
|
||||
| Image
|
||||
| { kind: 'nodes'; nodes: Inline[] }
|
||||
| TextBreak
|
||||
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
|
||||
@@ -75,7 +79,6 @@ type SlotContent = { nodes: Inline[] }
|
||||
const ownMarksInMark = 'move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks'
|
||||
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
|
||||
const hreflessLink = 'the link mark spells its href: this one spells none'
|
||||
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
|
||||
const linkInLink = 'no link wraps a link: the [content] this one marks already holds one'
|
||||
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
|
||||
const textBreak: TextBreak = { kind: 'textBreak' }
|
||||
@@ -85,7 +88,6 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions,
|
||||
const scan = freshScan(source, { definitions, path }, { claims, container, spans: noSpans })
|
||||
const scanned = scanInline(scan)
|
||||
if (!scanned.ok) return scanned
|
||||
if (scanned.value.image !== undefined) return success({ image: scanned.value.image })
|
||||
const nodes = partText(scanned.value.nodes, scan)
|
||||
if (!nodes.ok) return nodes
|
||||
if (scan.openingSpellableLink) {
|
||||
@@ -93,7 +95,8 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions,
|
||||
if (!takesDirective.ok) return takesDirective
|
||||
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path)
|
||||
}
|
||||
return success({ nodes: nodes.value })
|
||||
const { image } = scanned.value
|
||||
return success(image === undefined ? { nodes: nodes.value } : { image, nodes: nodes.value })
|
||||
}
|
||||
|
||||
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'claims' | 'container' | 'spans'>): Scan {
|
||||
@@ -279,8 +282,7 @@ function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | unde
|
||||
const { claims, definitions, path } = scan
|
||||
const parsed = scanInline(freshScan(span.content, { definitions, path }, { claims, container: undefined, spans: span.spans }))
|
||||
if (!parsed.ok) return parsed
|
||||
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
|
||||
return success(parsed.value)
|
||||
return success({ nodes: parsed.value.nodes })
|
||||
}
|
||||
|
||||
function flush(scan: Scan, strip: boolean): void {
|
||||
@@ -294,12 +296,34 @@ function pushNode(scan: Scan, node: AdfNode): void {
|
||||
}
|
||||
|
||||
function assemble(scan: Scan): Result<Scanned> {
|
||||
const only = scan.pieces[0]
|
||||
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
|
||||
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
|
||||
const nodes = resolveNodes(scan.pieces, scan, true)
|
||||
const nodes = resolveNodes(scan.pieces.map(linkedAlt), scan, true)
|
||||
if (!nodes.ok) return nodes
|
||||
return success({ nodes: nodes.value })
|
||||
const [only] = scan.pieces
|
||||
return success(scan.pieces.length === 1 && only?.kind === 'image' ? { image: mediaSingle(only), nodes: nodes.value } : { nodes: nodes.value })
|
||||
}
|
||||
|
||||
// spec/flavour.md, The CommonMark image: an image no paragraph holds alone reads as its alt text, linked.
|
||||
function linkedAlt(piece: Piece): Piece {
|
||||
if (piece.kind !== 'image') return piece
|
||||
return { kind: 'nodes', nodes: linkTo(piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }], piece.link ?? piece.definition) }
|
||||
}
|
||||
|
||||
function mediaSingle({ alt, definition, link }: Image): AdfNode {
|
||||
const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination }
|
||||
const media: AdfNode = link === undefined ? { attrs, type: 'media' } : { attrs, marks: [linkMark(link)], type: 'media' }
|
||||
const { title } = definition
|
||||
const caption: AdfNode[] = title === undefined || title === '' ? [] : [{ content: [{ text: title, type: 'text' }], type: 'caption' }]
|
||||
return { attrs: { layout: 'center' }, content: [media, ...caption], type: 'mediaSingle' }
|
||||
}
|
||||
|
||||
function linkMark({ destination, title }: LinkDefinition): AdfMark {
|
||||
return { attrs: title === undefined ? { href: destination } : { href: destination, title }, type: 'link' }
|
||||
}
|
||||
|
||||
// ADF holds no empty text node for a link mark to ride, so an empty link text reads as its destination, and an empty destination as nothing.
|
||||
function linkTo(nodes: readonly Inline[], definition: LinkDefinition): Inline[] {
|
||||
if (nodes.length > 0) return applyMark(nodes, linkMark(definition))
|
||||
return definition.destination === '' ? [] : [{ marks: [linkMark(definition)], text: definition.destination, type: 'text' }]
|
||||
}
|
||||
|
||||
// spec/flavour.md, Inline nodes: the leaf builds no node, so only the pair it parts spells it.
|
||||
@@ -340,13 +364,9 @@ function holdsOwnMarks(item: Inline | Piece): boolean {
|
||||
return 'kind' in item && item.kind === 'ownMarks'
|
||||
}
|
||||
|
||||
function holdsImage(pieces: readonly Piece[]): boolean {
|
||||
return pieces.some((piece) => piece.kind === 'image')
|
||||
}
|
||||
|
||||
// Only a link a `nodes` piece spells makes the outer brackets literal; an `ownMarks` node's link is its own, so the own-marks guard in `closeLink` refuses it.
|
||||
// Only a link a `nodes` piece spells, or an image's, makes the outer brackets literal; an `ownMarks` node's link is its own, so the own-marks guard in `closeLink` refuses it.
|
||||
function holdsLink(pieces: readonly Piece[]): boolean {
|
||||
return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes))
|
||||
return pieces.some((piece) => (piece.kind === 'nodes' && marksLink(piece.nodes)) || (piece.kind === 'image' && piece.link !== undefined))
|
||||
}
|
||||
|
||||
function marksLink(nodes: readonly Inline[]): boolean {
|
||||
@@ -440,25 +460,28 @@ function resolveTarget(scan: Scan, bracket: Bracket, index: number): { definitio
|
||||
}
|
||||
|
||||
function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<boolean> {
|
||||
// Ahead of the guards below: brackets going literal put the image and the carry inside no mark for either to refuse.
|
||||
// Ahead of the guard below: brackets going literal put the carry inside no mark for it to refuse.
|
||||
if (holdsLink(inner)) {
|
||||
deactivateOpeners(scan, at)
|
||||
return success(false)
|
||||
}
|
||||
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
|
||||
if (inner.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
|
||||
const resolved = resolveNodes(inner, scan, true)
|
||||
if (!resolved.ok) return resolved
|
||||
const nodes = resolved.value
|
||||
// An empty link text gives the mark no node to ride, so the brackets stay text.
|
||||
if (nodes.length === 0) return success(false)
|
||||
const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title }
|
||||
const piece = linkPiece(scan, inner, definition)
|
||||
if (!piece.ok) return piece
|
||||
truncatePieces(scan, at)
|
||||
deactivateOpeners(scan, at)
|
||||
scan.pieces.push({ kind: 'nodes', nodes: applyMark(nodes, { attrs, type: 'link' }) })
|
||||
scan.pieces.push(piece.value)
|
||||
return success(true)
|
||||
}
|
||||
|
||||
// An image alone in the link text keeps its piece, so a paragraph holding nothing else builds the linked image.
|
||||
function linkPiece(scan: Scan, inner: readonly Piece[], definition: LinkDefinition): Result<Piece> {
|
||||
const [only] = inner
|
||||
if (inner.length === 1 && only?.kind === 'image') return success({ ...only, link: definition })
|
||||
const nodes = resolveNodes(inner, scan, true)
|
||||
return nodes.ok ? success({ kind: 'nodes', nodes: linkTo(nodes.value, definition) }) : nodes
|
||||
}
|
||||
|
||||
// CommonMark: no link nests inside another, though an image's description holds one.
|
||||
function deactivateOpeners(scan: Scan, before: number): void {
|
||||
for (let index = scan.deactivatedBefore; index < before; index += 1) {
|
||||
@@ -474,13 +497,10 @@ function truncatePieces(scan: Scan, to: number): void {
|
||||
}
|
||||
|
||||
function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<null> {
|
||||
if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path)
|
||||
const resolved = imageAlt(inner, scan)
|
||||
if (!resolved.ok) return resolved
|
||||
const alt = resolved.value
|
||||
const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination }
|
||||
const alt = imageAlt(inner, scan)
|
||||
if (!alt.ok) return alt
|
||||
truncatePieces(scan, at)
|
||||
scan.pieces.push({ alt, kind: 'image', node: { attrs: { layout: 'center' }, content: [{ attrs, type: 'media' }], type: 'mediaSingle' } })
|
||||
scan.pieces.push({ alt: alt.value, definition, kind: 'image' })
|
||||
return success(null)
|
||||
}
|
||||
|
||||
@@ -527,7 +547,7 @@ function mergeReadText(items: readonly Inline[]): Inline[] {
|
||||
return merged
|
||||
}
|
||||
|
||||
// Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first.
|
||||
// The image arm is an image inside an image's description or a link's text, which reads as its plain alt text: `assemble` links every other.
|
||||
// A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it.
|
||||
function pieceNodes(piece: Piece): Inline[] {
|
||||
switch (piece.kind) {
|
||||
|
||||
@@ -67,6 +67,11 @@ function link(href: string, title?: string): AdfMark {
|
||||
return { attrs: title === undefined ? { href } : { href, title }, type: 'link' }
|
||||
}
|
||||
|
||||
function firstContent(markdown: string): unknown {
|
||||
const [block] = content(losslessMarkdownToAdf(markdown))
|
||||
return typeof block === 'object' ? block.content : block
|
||||
}
|
||||
|
||||
function image(url: string, alt?: string): AdfNode {
|
||||
const media: AdfNode = { attrs: alt === undefined ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' }
|
||||
return { attrs: { layout: 'center' }, content: [media], type: 'mediaSingle' }
|
||||
@@ -289,9 +294,10 @@ test('refuses the list separator that parts anything else', () => {
|
||||
assert.deepEqual(path(losslessMarkdownToAdf('Part.\n\n!adf:listBreak\n')), ['content', 1])
|
||||
})
|
||||
|
||||
test('refuses the image a pipe cell holds no ADF node for', () => {
|
||||
assert.equal(content(losslessMarkdownToAdf('| a |\n| --- |\n|  |\n')), 'unmappable-image: no ADF node carries an image inside a paragraph')
|
||||
assert.deepEqual(path(losslessMarkdownToAdf('| a |\n| --- |\n|  |\n')), ['content', 0, 'content', 1, 'content', 0, 'content', 0])
|
||||
test('reads the image a pipe cell holds as its linked alt text', () => {
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('| a |\n| --- |\n|  |\n')), [
|
||||
{ content: [{ content: [cell('tableHeader', text('a'))], type: 'tableRow' }, { content: [cell('tableCell', marked('x', link('/u')))], type: 'tableRow' }], type: 'table' },
|
||||
])
|
||||
})
|
||||
|
||||
test('strips the opening fence indentation from the content lines it holds', () => {
|
||||
@@ -567,7 +573,6 @@ test('names the argument and the body a node takes no reading for', () => {
|
||||
assert.equal(content(losslessMarkdownToAdf('!adf:paragraph\n---\n!adf:/paragraph\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body: this body is not one')
|
||||
assert.equal(content(losslessMarkdownToAdf('!adf:codeBlock {wrap=true}\nx\n!adf:/codeBlock\n')), 'unsupported-node-shape: codeBlock takes code blocks as its body: this body holds another block')
|
||||
assert.equal(content(losslessMarkdownToAdf('!adf:codeBlock {wrap=true}\n!adf:/codeBlock\n')), 'unsupported-node-shape: codeBlock takes code blocks as its body: this body holds none')
|
||||
assert.equal(content(losslessMarkdownToAdf('!adf:paragraph\n\n!adf:/paragraph\n')), 'unmappable-image: no ADF node carries an image inside a paragraph')
|
||||
assert.equal(content(losslessMarkdownToAdf('Part !adf:date[now]{timestamp=1}.\n')), 'unsupported-node-shape: date takes no content: this one holds some')
|
||||
})
|
||||
|
||||
@@ -992,25 +997,42 @@ test('flattens the description of a lone image to the plain text alt holds', ()
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('![!adf:mention[@A]{id=b1c2}](/u)\n')), [image('/u', '@A')])
|
||||
})
|
||||
|
||||
test('leaves the brackets of an empty link text the text they are', () => {
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('[](/u)\n')), [paragraph('[](/u)')])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('a [](/u) b\n')), [paragraph('a [](/u) b')])
|
||||
// The pair gives the label back the way an unresolved one does, so the shortcut behind it still reads.
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('[][r]\n\n[r]: /u\n')), [{ content: [text('[]'), marked('r', link('/u'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('\n')), [image('/u')])
|
||||
test('reads an empty link text as its destination, and an empty destination as nothing', () => {
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('[](/u)\n')), [{ content: [marked('/u', link('/u'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('a [](/u "t") b\n')), [{ content: [text('a '), marked('/u', link('/u', 't')), text(' b')], type: 'paragraph' }])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('[][r]\n\n[r]: /u\n')), [{ content: [marked('/u', link('/u'))], type: 'paragraph' }])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('a []() b\n')), [paragraph('a b')])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('[]()\n')), [{ type: 'paragraph' }])
|
||||
})
|
||||
|
||||
test('refuses the image no ADF node carries where it sits', () => {
|
||||
assert.equal(content(losslessMarkdownToAdf('\n')), 'unmappable-image: no media node carries a link title')
|
||||
assert.equal(content(losslessMarkdownToAdf('See .\n')), 'unmappable-image: an image fits only as a paragraph of its own: this one sits inside other content')
|
||||
assert.equal(code(losslessMarkdownToAdf('# \n')), 'unmappable-image')
|
||||
assert.equal(code(losslessMarkdownToAdf('**\n')), 'unmappable-image')
|
||||
assert.equal(code(losslessMarkdownToAdf('[](/v)\n')), 'unmappable-image')
|
||||
assert.equal(code(losslessMarkdownToAdf('\n')), 'unmappable-image')
|
||||
assert.equal(code(losslessMarkdownToAdf(' d\n')), 'unmappable-image')
|
||||
assert.deepEqual(path(losslessMarkdownToAdf('Part.\n\nSee .\n')), ['content', 1])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('![a]\n')), [paragraph('![a]')])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('a ! b\n')), [paragraph('a ! b')])
|
||||
test('reads a titled image alone in its paragraph as the image captioned with its title', () => {
|
||||
const caption: AdfNode = { content: [text('The moon & stars')], type: 'caption' }
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('\n')), [{ ...image('/u', 'a'), content: [...(image('/u', 'a').content ?? []), caption] }])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('\n')), [image('/u', 'a')])
|
||||
})
|
||||
|
||||
test('reads an image alone in a link as the image the link marks, and the link a title gives as its own', () => {
|
||||
const linked = (node: AdfNode, mark: AdfMark): AdfNode => ({ ...node, content: (node.content ?? []).map((media) => ({ ...media, marks: [mark] })) })
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('[](/v "t")\n')), [linked(image('/u', 'a'), link('/v', 't'))])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('[![a][i]][v]\n\n[i]: /u\n[v]: /v\n')), [linked(image('/u', 'a'), link('/v'))])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('[[](/v)](/w)\n')), [{ content: [text('['), marked('a', link('/v')), text('](/w)')], type: 'paragraph' }])
|
||||
})
|
||||
|
||||
test('reads an image no paragraph holds alone as its alt text, linked to its destination', () => {
|
||||
assert.deepEqual(firstContent('See .\n'), [text('See '), marked('a', link('/u', 't')), text('.')])
|
||||
assert.deepEqual(firstContent('See .\n'), [text('See '), marked('/u', link('/u')), text('.')])
|
||||
assert.deepEqual(firstContent('See ![]().\n'), [text('See .')])
|
||||
assert.deepEqual(firstContent('**\n'), [marked('a', { type: 'em' }, link('/u'))])
|
||||
assert.deepEqual(firstContent('\n'), [marked('a', link('/u')), marked('b', link('/v'))])
|
||||
assert.deepEqual(firstContent('# \n'), [marked('a', link('/u'))])
|
||||
assert.deepEqual(firstContent('!adf:paragraph {localId=p}\n\n!adf:/paragraph\n'), [marked('a', link('/u'))])
|
||||
assert.deepEqual(firstContent(' d\n'), [text('![a '), marked('b', link('/c')), text(' d')])
|
||||
})
|
||||
|
||||
test('reads an image beside other content in a link as its alt text, the link\'s own text', () => {
|
||||
assert.deepEqual(firstContent('See [](/v).\n'), [text('See '), marked('a', link('/v')), text('.')])
|
||||
assert.deepEqual(firstContent('See [](/v).\n'), [text('See '), marked('/v', link('/v')), text('.')])
|
||||
assert.deepEqual(firstContent('[b ](/v)\n'), [marked('b a', link('/v'))])
|
||||
})
|
||||
|
||||
test('carries the mark a spelling nested inside its own kind names once', () => {
|
||||
@@ -1041,7 +1063,7 @@ test('names the content slot no lone plain text node reads back from', () => {
|
||||
assert.equal(content(losslessMarkdownToAdf('!adf:status[!adf:carry{json="{\\"content\\":[{\\"text\\":\\"B\\",\\"type\\":\\"text\\"}],\\"text\\":\\"A\\",\\"type\\":\\"text\\"}"}]{color=yellow}\n')), named)
|
||||
assert.equal(code(losslessMarkdownToAdf('!adf:status[a`b`]{color=yellow}\n')), 'unsupported-node-shape')
|
||||
assert.equal(code(losslessMarkdownToAdf('!adf:status[!adf:date{timestamp=1}]{color=yellow}\n')), 'unsupported-node-shape')
|
||||
assert.equal(content(losslessMarkdownToAdf('!adf:status[]{color=yellow}\n')), 'unmappable-image: an image fits only as a paragraph of its own: this one sits inside other content')
|
||||
assert.equal(content(losslessMarkdownToAdf('!adf:status[]{color=yellow}\n')), named)
|
||||
assert.equal(code(losslessMarkdownToAdf('!adf:status[<div>]{color=yellow}\n')), 'unmappable-html')
|
||||
assert.equal(code(losslessMarkdownToAdf('!adf:date[<div>]{timestamp=1}\n')), 'unmappable-html')
|
||||
assert.equal(code(losslessMarkdownToAdf('!adf:widget[<div>]\n')), 'unmappable-html')
|
||||
|
||||
@@ -20,14 +20,14 @@ import { readBlockDirectiveNode } from './directive-nodes.ts'
|
||||
import { spellStringAttribute, unsupportedNodeShape } from '../directive-syntax.ts'
|
||||
import { spellsEmpty } from '../empty-keys.ts'
|
||||
|
||||
type Paragraph = Extract<Block, { kind: 'paragraph' }>
|
||||
// A paragraph a marker opens: it shares its markdown paragraph with the marker, so no image there stands alone.
|
||||
type MarkedParagraph = { kind: 'markedParagraph'; position: SourcePosition; text: string }
|
||||
|
||||
type ReadBlock = Block | MarkedParagraph
|
||||
|
||||
// `inExpand` is whether an expand holds the blocks, which makes a folded callout a nestedExpand.
|
||||
type Reading = { claims: Claims; definitions: LinkDefinitions; inExpand: boolean; memo: SpellingMemo }
|
||||
|
||||
const imageAfterMarker = 'an image fits only as a paragraph of its own: this one continues the paragraph a marker opens, which a blank line before it ends'
|
||||
const imageOnMarkerLine = 'an image fits only as a paragraph of its own: this one shares a line with a marker'
|
||||
|
||||
export function commonMarkToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
||||
return readDocument(markdown, flavourClaims.commonmark)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ function typeName(value: unknown): string {
|
||||
return type === 'object' ? 'an object' : `a ${type}`
|
||||
}
|
||||
|
||||
function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||
function readBlocks(blocks: readonly ReadBlock[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path)
|
||||
const content: AdfNode[] = []
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
@@ -86,7 +86,7 @@ function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErr
|
||||
}
|
||||
|
||||
// spec/flavour.md, Directives: the separator builds no node, so only the pair it parts spells it.
|
||||
function listBreakFault(block: DirectiveBlock, previous: Block | undefined, next: Block | undefined): ConvertFault | undefined {
|
||||
function listBreakFault(block: DirectiveBlock, previous: ReadBlock | undefined, next: ReadBlock | undefined): ConvertFault | undefined {
|
||||
if (block.argument !== undefined || block.attributes.size > 0) {
|
||||
return unsupportedNodeShape(`${listBreakName} spells the bare leaf form, ${listBreakSpelling}: this one spells more`)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ function partsFault(): ConvertFault {
|
||||
return unsupportedNodeShape(`${listBreakName} parts two adjacent lists of one type: this one parts something else`)
|
||||
}
|
||||
|
||||
function readBlock(block: Block, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
function readBlock(block: ReadBlock, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
switch (block.kind) {
|
||||
case 'blockquote':
|
||||
return reading.claims.alerts ? quoteNode(block.blocks, reading, path, depth) : containerNode({ type: 'blockquote' }, block.blocks, reading, path, depth)
|
||||
@@ -123,6 +123,8 @@ function readBlock(block: Block, reading: Reading, path: ConvertErrorPath, depth
|
||||
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, reading, path, 'heading')
|
||||
case 'html':
|
||||
return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path)
|
||||
case 'markedParagraph':
|
||||
return contentNode({ type: 'paragraph' }, block.text, reading, path, 'paragraph')
|
||||
case 'orderedList':
|
||||
return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, reading, path, depth)
|
||||
case 'paragraph':
|
||||
@@ -147,8 +149,8 @@ function markerLine(text: string): { line: string; rest: string } {
|
||||
return { line: (hardBreak ? line.slice(0, -1) : line).replace(/^[ \t]+/, ''), rest: lineEnd === -1 ? '' : text.slice(lineEnd + 1) }
|
||||
}
|
||||
|
||||
function paragraphsOf(position: SourcePosition, ...texts: string[]): Paragraph[] {
|
||||
return texts.filter((text) => text !== '').map((text) => ({ kind: 'paragraph', position, text }))
|
||||
function paragraphsOf(position: SourcePosition, ...texts: string[]): MarkedParagraph[] {
|
||||
return texts.filter((text) => text !== '').map((text) => ({ kind: 'markedParagraph', position, text }))
|
||||
}
|
||||
|
||||
function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
@@ -157,13 +159,12 @@ function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErro
|
||||
if (led === undefined) return containerNode({ type: 'blockquote' }, blocks, reading, path, depth)
|
||||
const { folded, panelType } = led.marker
|
||||
const { line, rest } = markerLine(led.text)
|
||||
if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readMarked(paragraphsOf(led.position, line, rest), line !== '', body, reading, path, depth))
|
||||
if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readBlocks([...paragraphsOf(led.position, line, rest), ...body], reading, path, depth + 1))
|
||||
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.claims)
|
||||
if (!title.ok) return title
|
||||
if (title.value.image !== undefined) return failure('unmappable-image', imageOnMarkerLine, path)
|
||||
const text = titleText(title.value.nodes)
|
||||
const type = reading.inExpand ? 'nestedExpand' : 'expand'
|
||||
return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readMarked(paragraphsOf(led.position, rest), false, body, { ...reading, inExpand: true }, path, depth))
|
||||
return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readBlocks([...paragraphsOf(led.position, rest), ...body], { ...reading, inExpand: true }, path, depth + 1))
|
||||
}
|
||||
|
||||
// docs/decisions.md, A callout title keeps its link targets.
|
||||
@@ -192,15 +193,6 @@ function filledNode(node: AdfNode, content: Result<AdfNode[]>): Result<AdfNode>
|
||||
return success({ ...node, content: content.value.length === 0 ? [{ type: 'paragraph' }] : content.value })
|
||||
}
|
||||
|
||||
// A paragraph split off a marker still refuses the image it held beside it; `onMarkerLine` is whether the first one opens on the marker's line.
|
||||
function readMarked(marked: readonly Paragraph[], onMarkerLine: boolean, others: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||
const read = readBlocks([...marked, ...others], reading, path, depth + 1)
|
||||
if (!read.ok) return read
|
||||
const image = read.value.slice(0, marked.length).findIndex((node) => node.type === 'mediaSingle')
|
||||
if (image === -1) return read
|
||||
return failure('unmappable-image', image === 0 && onMarkerLine ? imageOnMarkerLine : imageAfterMarker, [...path, 'content', image])
|
||||
}
|
||||
|
||||
// A task list trailing an item's blocks stands beside it, as ADF nests one.
|
||||
function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const led = []
|
||||
@@ -211,7 +203,7 @@ function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertEr
|
||||
}
|
||||
const tasks: AdfNode[] = []
|
||||
for (const [index, { marker, others, position, text }] of led.entries()) {
|
||||
const read = readMarked(paragraphsOf(position, text.replace(/^(?:[ \t\n]|\\\n)+/, '')), markerLine(text).line !== '', others, reading, [...path, 'content', index], depth)
|
||||
const read = readBlocks([...paragraphsOf(position, text.replace(/^(?:[ \t\n]|\\\n)+/, '')), ...others], reading, [...path, 'content', index], depth + 1)
|
||||
if (!read.ok) return read
|
||||
let beside = read.value.length
|
||||
while (read.value[beside - 1]?.type === 'taskList') beside -= 1
|
||||
@@ -326,7 +318,7 @@ function codeBlockNode(language: string, text: string, reading: Reading, path: C
|
||||
return success(text === '' ? node : { ...node, content: [{ text, type: 'text' }] })
|
||||
}
|
||||
|
||||
// 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 a lone image the block it needs.
|
||||
function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
|
||||
const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.claims)
|
||||
if (!content.ok) return content
|
||||
@@ -337,6 +329,5 @@ function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath):
|
||||
function contentNode(node: AdfNode, text: string, reading: Reading, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> {
|
||||
const content = parseInlineContent(text, reading.definitions, path, container, reading.claims)
|
||||
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)
|
||||
return success(withContent(node, content.value.nodes))
|
||||
}
|
||||
|
||||
@@ -226,20 +226,17 @@ test('reads text the writer kept from reading as a marker back as text', () => {
|
||||
assert.deepEqual(roundTripped(bare('taskList', task('DONE', text('[x] ==a==')))), [bare('taskList', task('DONE', text('[x] ==a==')))])
|
||||
})
|
||||
|
||||
test('reads what CommonMark reads where no row reads, a directive and a carry fence among it, and refuses only what it refuses', () => {
|
||||
test('reads what CommonMark reads where no row reads, a directive and a carry fence among it, an image sharing its paragraph with a marker among it', () => {
|
||||
assert.deepEqual(read('!adf:panel\n'), [said('!adf:panel')])
|
||||
assert.deepEqual(read('a !adf:carry{json="{}"} \\!adf:x\n'), [said('a !adf:carry{json="{}"} !adf:x')])
|
||||
assert.deepEqual(read('```adf:blockCard\n{}\n```\n'), [node('codeBlock', { language: 'adf:blockCard' }, text('{}'))])
|
||||
const refusal = (markdown: string): unknown => {
|
||||
const parsed = portableMarkdownToAdf(markdown)
|
||||
return parsed.ok ? parsed.value : [parsed.error.code, parsed.error.message]
|
||||
}
|
||||
for (const markdown of ['> [!tip] \n', '> [!NOTE]- \n', '- [x] \n']) {
|
||||
assert.deepEqual(refusal(markdown), ['unmappable-image', 'an image fits only as a paragraph of its own: this one shares a line with a marker'], markdown)
|
||||
}
|
||||
for (const markdown of ['> [!tip]\n> \n', '> [!tip] t\n> \n', '> [!NOTE]- t\n> \n', '- [x]\n \n']) {
|
||||
assert.deepEqual(refusal(markdown), ['unmappable-image', 'an image fits only as a paragraph of its own: this one continues the paragraph a marker opens, which a blank line before it ends'], markdown)
|
||||
}
|
||||
const linked = paragraph(text('a', { attrs: { href: 'u' }, type: 'link' }))
|
||||
assert.deepEqual(read('> [!tip] \n'), [panel('tip', linked)])
|
||||
assert.deepEqual(read('> [!tip]\n> \n'), [panel('tip', linked)])
|
||||
assert.deepEqual(read('> [!NOTE]- \n'), [node('expand', { title: 'a (u)' }, paragraph())])
|
||||
assert.deepEqual(read('> [!NOTE]- t\n> \n'), [node('expand', { title: 't' }, linked)])
|
||||
assert.deepEqual(read('- [x] \n'), [bare('taskList', task('DONE', ...(linked.content ?? [])))])
|
||||
assert.deepEqual(read('> [!tip]\n>\n> \n'), [panel('tip', node('mediaSingle', { layout: 'center' }, { attrs: { alt: 'a', type: 'external', url: 'u' }, type: 'media' }))])
|
||||
let deep = 'x\n'
|
||||
for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}`
|
||||
assert.equal(typeof read(deep), 'object')
|
||||
|
||||
@@ -5,7 +5,6 @@ export type ConvertErrorCode =
|
||||
| 'not-an-adf-document'
|
||||
| 'unknown-directive-name'
|
||||
| 'unmappable-html'
|
||||
| 'unmappable-image'
|
||||
| 'unspellable-whitespace'
|
||||
| 'unsupported-document-version'
|
||||
| 'unsupported-nesting-depth'
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
`Bar = 9`
|
||||
|
||||
`Next ID = 85`
|
||||
`Next ID = 87`
|
||||
|
||||
| Goal | W |
|
||||
|---|---|
|
||||
@@ -25,13 +25,13 @@
|
||||
|
||||
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 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 | 4, 5, 7 | 17.1 |
|
||||
| 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists, in every reader, and retire `!adf:listBreak`.** | 4 | 5 | 5 | 8 | 4, 5 | 15.5 |
|
||||
| 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 |
|
||||
| 86 | 0.2.0 | | **Write an image a link alone marks as `[](href)`, and one captioned with plain text as ``, in `adfToLosslessMarkdown`.** | 4 | 4 | 5 | 6 | 4, 6 | 13.0 |
|
||||
| 85 | 0.2.0 | defect | **Write an external image a link marks as `[](href)` in `adfToPortableMarkdown`, keeping the link target.** | 3 | 3 | 6 | 7 | 7 | 12.9 |
|
||||
| 60 | 0.2.0 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 2 | 6 | 2 | 10.7 |
|
||||
| 59 | 0.2.0 | decision | **Group the directive grammar into `src/markdown/directive/`, move `Read<T>` to `result.ts`, and move `Flavour` and `flavourClaims` to `markdown/flavour.ts`.** | 3 | 5 | 2 | 6 | 2 | 10.4 |
|
||||
| 62 | 0.2.0 | decision | **Move the portable flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/portable/`, so `emit/` no longer imports `portable/`.** | 4 | 5 | 2 | 6 | 2 | 9.4 |
|
||||
| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 4, 5, 7 | 9.0 |
|
||||
| 7 | 0.3.0 | decision | **Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.** | 6 | 9 | 9 | 9 | 2, 4 | 25.8 |
|
||||
| 45 | 0.3.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 |
|
||||
| 6 | 0.3.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 4 | 24.7 |
|
||||
@@ -65,19 +65,6 @@
|
||||
|
||||
## Details
|
||||
|
||||
### 65. 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.
|
||||
|
||||
Both are refused today with `unmappable-image`: 14 examples in
|
||||
`corpus/commonmark-spec/refusals.json`, a bot's `See  here` among them. `mediaInline`
|
||||
takes only Atlassian media ids, never a URL. An image not alone in its paragraph reads as its alt
|
||||
text, linked to its URL. Its title becomes the link's `title`. An empty alt text reads as the URL,
|
||||
as item 50 reads an empty link. A titled image alone in its paragraph reads as the image, with its
|
||||
title as a `caption` (the maintainer, 2026-10-04). By Goal 5, every reader reads both as CommonMark
|
||||
does. An image inside a link: `[](/uri)` (spec example 517). Alone in its
|
||||
paragraph, it reads as the image with a `link` mark to `/uri`, which ADF's `media` takes. Not 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 6, which outranks Goal 7.
|
||||
|
||||
### 49. Read a list whose bullet or ordered delimiter changes as two lists, in every reader, and retire `!adf:listBreak`.
|
||||
|
||||
Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads two (spec examples
|
||||
@@ -86,7 +73,7 @@ changing the marker, so `adfToLosslessMarkdown` and `adfToPortableMarkdown` alte
|
||||
between adjacent bullet lists and `.` and `)` between adjacent ordered lists, and `!adf:listBreak`
|
||||
retires. `*` is the maintainer's pick (2026-10-04). Breaking: `MIGRATION.md`'s Readings and
|
||||
Spellings tables gain their rows. Examples 301 and 302 lose their `pending` exceptions, and the
|
||||
spelling leaves the README's "Four CommonMark spellings" bullet, which counts one fewer.
|
||||
spelling leaves the README's "Six CommonMark spellings" bullet, which counts one fewer.
|
||||
|
||||
### 61. Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.
|
||||
|
||||
@@ -96,6 +83,17 @@ only the property tests catch drift. It caps the panel's Locality score. The rep
|
||||
time linear in the line: today `mergesWithSyntax`, `touchesSyntax` and `closesHeading` rescan a run
|
||||
of one character from each of its characters.
|
||||
|
||||
### 86. Write an image a link alone marks as `[](href)`, and one captioned with plain text as ``, in `adfToLosslessMarkdown`.
|
||||
|
||||
Every reader reads both spellings so; `adfToLosslessMarkdown` writes the `!adf:mediaSingle`
|
||||
directive for them. The emitted form is API (`docs/decisions.md` §The formats are API), so it
|
||||
changes before `0.2.0` ships. A writer panel settles the spelling.
|
||||
|
||||
### 85. Write an external image a link marks as `[](href)` in `adfToPortableMarkdown`, keeping the link target.
|
||||
|
||||
Today `[](/v)` reads as the image whose `media` the link marks, and `adfToPortableMarkdown`
|
||||
writes ``, dropping `/v`: the README promises it keeps link targets (Goal 7).
|
||||
|
||||
### 60. Collect the questions `parse/` asks `emit/` into one named module.
|
||||
|
||||
`parse/` asks `emit/` through `commonMarkSpelling` and `openingLinkTakesDirective`, each imported
|
||||
@@ -119,14 +117,6 @@ Writing: `spellPortableBlock`, `quotedUnder`, `tryTaskList` and `taskBlocks` in
|
||||
`flavourClaims`. Every comprehension reader on 2026-10-03 named the portable flavour's spread across
|
||||
three directories.
|
||||
|
||||
### 50. Read `[](/url)` and `[]()` as CommonMark's empty link.
|
||||
|
||||
Both stay literal text today (spec examples 484 and 487). ADF holds no empty text node to carry a
|
||||
link mark, so a link whose text is empty takes its URL as its text (the maintainer, 2026-10-04):
|
||||
`[](/url)` reads as `/url` linked to `/url`. `[]()` has no URL to show; the chunk settles it by
|
||||
Goals 4 and 7. Breaking: `MIGRATION.md`'s Readings table gains its row. Its `pending` exceptions go,
|
||||
and its spelling leaves the README's "Four CommonMark spellings" bullet, which counts one fewer.
|
||||
|
||||
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.
|
||||
|
||||
Lands after items 6, 59, 60, 61 and 62. Which flavours get a composed call is this item's to settle;
|
||||
@@ -250,7 +240,7 @@ A writer panel chose it on 2026-10-03, 5 of 7, over today's `colwidth="[340,420]
|
||||
|
||||
`link-syntax.ts` normalizes a label with `toLowerCase`, so `[ẞ]` misses its `[SS]` definition (spec
|
||||
example 540); lowercasing and then uppercasing folds it. Breaking: `MIGRATION.md`'s Readings table
|
||||
gains its row. Its `pending` exceptions go, and its spelling leaves the README's "Four CommonMark
|
||||
gains its row. Its `pending` exceptions go, and its spelling leaves the README's "Six CommonMark
|
||||
spellings" bullet, which counts one fewer.
|
||||
|
||||
### 53. Put a block's `marks` spelling to the writer panel and adopt its pick.
|
||||
|
||||
Reference in New Issue
Block a user