From 3b621a9cef8c9cec3e81dec7bf54ab9b2b8a6a40 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 9 Oct 2026 03:28:12 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 5 + MIGRATION.md | 14 +- README.md | 31 ++-- corpus/README.md | 4 +- corpus/commonmark-spec/exceptions.json | 126 +++++++++++--- corpus/commonmark-spec/refusals.json | 56 ------- corpus/errors/image-in-text.error | 1 - corpus/errors/image-in-text.md | 1 - corpus/normalization/images.json | 158 ++++++++++++++++++ corpus/normalization/images.md | 9 + docs/decisions.md | 5 +- spec/flavour.md | 28 ++-- src/conformance/commonmark-spec.test.ts | 20 +-- src/markdown/parse/inline-content.ts | 90 ++++++---- src/markdown/parse/markdown-to-adf.test.ts | 66 +++++--- src/markdown/parse/markdown-to-adf.ts | 39 ++--- .../parse/portable-markdown-to-adf.test.ts | 19 +-- src/result.ts | 1 - todo.md | 42 ++--- 19 files changed, 473 insertions(+), 242 deletions(-) delete mode 100644 corpus/errors/image-in-text.error delete mode 100644 corpus/errors/image-in-text.md create mode 100644 corpus/normalization/images.json create mode 100644 corpus/normalization/images.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d4c836e..1e2787a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, diff --git a/MIGRATION.md b/MIGRATION.md index 2c7b5bc..90a6207 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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 (`[ab](/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 ![a](u).`, `# ![a](u)`), 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 (`![a](u "t")`), on parse | `unmappable-image` | the image, captioned with its title | +| an image that is a link's whole text, alone in its paragraph (`[![a](u)](/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"}` | diff --git a/README.md b/README.md index 11dd0a4..b65eef3 100644 --- a/README.md +++ b/README.md @@ -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 `` 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 ![the + moon](moon.png).` 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 `` 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 diff --git a/corpus/README.md b/corpus/README.md index 5b969a9..c4c6a12 100644 --- a/corpus/README.md +++ b/corpus/README.md @@ -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 diff --git a/corpus/commonmark-spec/exceptions.json b/corpus/commonmark-spec/exceptions.json index 1d8c723..4e4af4d 100644 --- a/corpus/commonmark-spec/exceptions.json +++ b/corpus/commonmark-spec/exceptions.json @@ -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." diff --git a/corpus/commonmark-spec/refusals.json b/corpus/commonmark-spec/refusals.json index 083420f..93f351a 100644 --- a/corpus/commonmark-spec/refusals.json +++ b/corpus/commonmark-spec/refusals.json @@ -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 diff --git a/corpus/errors/image-in-text.error b/corpus/errors/image-in-text.error deleted file mode 100644 index 7fe8b78..0000000 --- a/corpus/errors/image-in-text.error +++ /dev/null @@ -1 +0,0 @@ -unmappable-image diff --git a/corpus/errors/image-in-text.md b/corpus/errors/image-in-text.md deleted file mode 100644 index ede305f..0000000 --- a/corpus/errors/image-in-text.md +++ /dev/null @@ -1 +0,0 @@ -See ![the moon](https://example.com/moon.png) tonight. diff --git a/corpus/normalization/images.json b/corpus/normalization/images.json new file mode 100644 index 0000000..d1799c1 --- /dev/null +++ b/corpus/normalization/images.json @@ -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 +} diff --git a/corpus/normalization/images.md b/corpus/normalization/images.md new file mode 100644 index 0000000..9756344 --- /dev/null +++ b/corpus/normalization/images.md @@ -0,0 +1,9 @@ +See ![the moon](https://example.com/moon.png "Full") tonight, or ![](https://example.com/sun.png). + +![the moon](https://example.com/moon.png "The moon, at night.") + +[![the moon](https://example.com/moon.png)](https://example.com/) + +Look [here ![the moon](https://example.com/moon.png)](https://example.com/), [](https://example.com/) and [](). + +# ![the moon](https://example.com/moon.png) diff --git a/docs/decisions.md b/docs/decisions.md index a6bc805..7149f02 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 diff --git a/spec/flavour.md b/spec/flavour.md index 0d19633..0cbf9b4 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -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, `![alt](url "title")`, adds a `caption` +holding the title as one text node, and a link whose entire text is the image, +`[![alt](url)](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. diff --git a/src/conformance/commonmark-spec.test.ts b/src/conformance/commonmark-spec.test.ts index abb7905..974df20 100644 --- a/src/conformance/commonmark-spec.test.ts +++ b/src/conformance/commonmark-spec.test.ts @@ -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 = { blockquote: 'blockquote', @@ -148,15 +148,13 @@ function nodeCounts(document: AdfNode): Record { while (pending.length > 0) { const node = pending.pop() if (node === undefined) continue - if (node.text !== undefined) { - const seen = new Set() - 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() + 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 diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 4a5a719..349a56e 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -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 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 @@ -44,7 +48,7 @@ type Pairing = EmphasisPairing 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 ' 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 { @@ -279,8 +282,7 @@ function slotContent(scan: Scan, span: DirectiveSpan): Result { - 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 { - // 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 { + 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 { - 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) { diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index ab65c73..65a7502 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -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| ![x](/u) |\n')), 'unmappable-image: no ADF node carries an image inside a paragraph') - assert.deepEqual(path(losslessMarkdownToAdf('| a |\n| --- |\n| ![x](/u) |\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| ![x](/u) |\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![a](/u)\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('![](/u)\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('![a](/u "t")\n')), 'unmappable-image: no media node carries a link title') - assert.equal(content(losslessMarkdownToAdf('See ![a](/u).\n')), 'unmappable-image: an image fits only as a paragraph of its own: this one sits inside other content') - assert.equal(code(losslessMarkdownToAdf('# ![a](/u)\n')), 'unmappable-image') - assert.equal(code(losslessMarkdownToAdf('*![a](/u)*\n')), 'unmappable-image') - assert.equal(code(losslessMarkdownToAdf('[![a](/u)](/v)\n')), 'unmappable-image') - assert.equal(code(losslessMarkdownToAdf('![a](/u)![b](/v)\n')), 'unmappable-image') - assert.equal(code(losslessMarkdownToAdf('![a ![b](/c) d\n')), 'unmappable-image') - assert.deepEqual(path(losslessMarkdownToAdf('Part.\n\nSee ![a](/u).\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('![a](/u "The moon & stars")\n')), [{ ...image('/u', 'a'), content: [...(image('/u', 'a').content ?? []), caption] }]) + assert.deepEqual(content(losslessMarkdownToAdf('![a](/u "")\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('[![a](/u)](/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('[[![a](/u)](/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 ![a](/u "t").\n'), [text('See '), marked('a', link('/u', 't')), text('.')]) + assert.deepEqual(firstContent('See ![](/u).\n'), [text('See '), marked('/u', link('/u')), text('.')]) + assert.deepEqual(firstContent('See ![]().\n'), [text('See .')]) + assert.deepEqual(firstContent('*![a](/u)*\n'), [marked('a', { type: 'em' }, link('/u'))]) + assert.deepEqual(firstContent('![a](/u)![b](/v)\n'), [marked('a', link('/u')), marked('b', link('/v'))]) + assert.deepEqual(firstContent('# ![a](/u)\n'), [marked('a', link('/u'))]) + assert.deepEqual(firstContent('!adf:paragraph {localId=p}\n![a](/u)\n!adf:/paragraph\n'), [marked('a', link('/u'))]) + assert.deepEqual(firstContent('![a ![b](/c) 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 [![a](/u "t")](/v).\n'), [text('See '), marked('a', link('/v')), text('.')]) + assert.deepEqual(firstContent('See [![](/u)](/v).\n'), [text('See '), marked('/v', link('/v')), text('.')]) + assert.deepEqual(firstContent('[b ![a](/u)](/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[![a](/u)]{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[![a](/u)]{color=yellow}\n')), named) assert.equal(code(losslessMarkdownToAdf('!adf:status[
]{color=yellow}\n')), 'unmappable-html') assert.equal(code(losslessMarkdownToAdf('!adf:date[
]{timestamp=1}\n')), 'unmappable-html') assert.equal(code(losslessMarkdownToAdf('!adf:widget[
]\n')), 'unmappable-html') diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 70b9155..3bc9dac 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -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 +// 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 { 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 { +function readBlocks(blocks: readonly ReadBlock[], reading: Reading, path: ConvertErrorPath, depth: number): Result { 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 { +function readBlock(block: ReadBlock, reading: Reading, path: ConvertErrorPath, depth: number): Result { 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 { @@ -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): Result 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 { - 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 { 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 { 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 { 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)) } diff --git a/src/markdown/parse/portable-markdown-to-adf.test.ts b/src/markdown/parse/portable-markdown-to-adf.test.ts index 355cb6d..ca5efdc 100644 --- a/src/markdown/parse/portable-markdown-to-adf.test.ts +++ b/src/markdown/parse/portable-markdown-to-adf.test.ts @@ -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] ![a](u)\n', '> [!NOTE]- ![a](u)\n', '- [x] ![a](u)\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> ![a](u)\n', '> [!tip] t\n> ![a](u)\n', '> [!NOTE]- t\n> ![a](u)\n', '- [x]\n ![a](u)\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] ![a](u)\n'), [panel('tip', linked)]) + assert.deepEqual(read('> [!tip]\n> ![a](u)\n'), [panel('tip', linked)]) + assert.deepEqual(read('> [!NOTE]- ![a](u)\n'), [node('expand', { title: 'a (u)' }, paragraph())]) + assert.deepEqual(read('> [!NOTE]- t\n> ![a](u)\n'), [node('expand', { title: 't' }, linked)]) + assert.deepEqual(read('- [x] ![a](u)\n'), [bare('taskList', task('DONE', ...(linked.content ?? [])))]) + assert.deepEqual(read('> [!tip]\n>\n> ![a](u)\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') diff --git a/src/result.ts b/src/result.ts index 162c7e2..90166f7 100644 --- a/src/result.ts +++ b/src/result.ts @@ -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' diff --git a/todo.md b/todo.md index 681f883..feb4b30 100644 --- a/todo.md +++ b/todo.md @@ -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 `[![alt](url)](href)`, and one captioned with plain text as `![alt](url "caption")`, in `adfToLosslessMarkdown`.** | 4 | 4 | 5 | 6 | 4, 6 | 13.0 | +| 85 | 0.2.0 | defect | **Write an external image a link marks as `[![alt](url)](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` 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`.** | 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 ![diagram](url) 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: `[![moon](moon.jpg)](/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 `[![alt](url)](href)`, and one captioned with plain text as `![alt](url "caption")`, 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 `[![alt](url)](href)` in `adfToPortableMarkdown`, keeping the link target. + +Today `[![a](/u)](/v)` reads as the image whose `media` the link marks, and `adfToPortableMarkdown` +writes `![a](/u)`, 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.