diff --git a/AGENTS.md b/AGENTS.md
index b0fedeb..6ecaf7f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,7 +25,7 @@ In `docs/decisions.md`:
- CommonMark is a subset
- Tables
- Links
-- Every CommonMark image and empty link reads: the image where it stands alone, else a link, `[]()` as nothing
+- An image reads as an image only alone in its paragraph, else as its alt text linked; an empty link text reads as its destination
- Ids stay site-local
- Portable task ids come from position
- A callout title keeps its link targets
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa4b761..6c63fea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -57,11 +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 anywhere
- but alone in a plain paragraph reads as its alt text linked to its URL, or inside a link as plain
- text the link marks. A titled one alone in a plain paragraph reads as the image captioned with
- its title, and a link alone in a plain paragraph whose whole text is an image as the image the
- link marks. To keep an image an image, give it a paragraph of its own. See `MIGRATION.md`.
+- **Breaking:** `unmappable-image` leaves `ConvertErrorCode`, and every image reads. An image reads
+ as an image only alone in its paragraph, and its title becomes the caption. Anywhere else it
+ reads as its alt text linked to its URL; inside a link, as plain text the link marks. A link
+ alone in its paragraph whose whole text is one image reads as that image, with the link marking
+ it. To keep an image an image, give it a paragraph of its own. See `MIGRATION.md`.
- **Breaking:** an empty link text reads as its destination, `[](/url)` as `/url` linked, and
`[]()` as nothing, where both spellings stayed literal text. See `MIGRATION.md`.
- Fix a JSON key holding an escape, in a carried node or a JSON attribute such as `parameters`:
diff --git a/MIGRATION.md b/MIGRATION.md
index a1fa028..3daa88d 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -74,14 +74,14 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
`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.
+three `unspellable-` codes 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 anywhere but alone in a plain paragraph (`See .`, `# `), on parse | `unmappable-image` | its alt text linked to its URL, or, inside a link, plain text the link marks; give the image a paragraph of its own, with a blank line between it and any text, to keep it an image |
+| an image not alone in its paragraph (`See .`, `# `), on parse | `unmappable-image` | its alt text linked to its URL, or, inside a link, plain text the link marks; give the image a paragraph of its own, a blank line between it and any other line, to keep it an image |
| 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 alone in its paragraph whose whole text is an image (`[](/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 abed14c..efbecfd 100644
--- a/README.md
+++ b/README.md
@@ -13,8 +13,7 @@ products store as ADF.
- **Markdown in:** `portableMarkdownToAdf` reads a bot's or a model's markdown, including `~~` and
pipe tables whose every row opens with a pipe. `commonMarkToAdf` reads strict CommonMark as its
spec says: nothing becomes a table, task or panel. Both refuse raw HTML and nesting past 500
- levels, and `portableMarkdownToAdf` a claimed shape that does not complete
- ([The errors](#the-errors)).
+ levels; `portableMarkdownToAdf` also refuses a malformed pipe table ([The errors](#the-errors)).
- **Portable markdown, lossy:** `adfToPortableMarkdown` and `portableMarkdownToAdf` convert to and
from the markdown that GitHub, GitLab and Obsidian render — GitHub Flavored Markdown's alerts,
task lists and pipe tables, Obsidian Flavored Markdown's callouts and highlights.
@@ -239,16 +238,17 @@ 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.
-- 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:
+- Seven CommonMark spellings parse without an error and build a document the reference
+ implementation renders differently. Four 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 anywhere but alone in a plain paragraph reads as its alt text linked to its URL,
- `See .` as `See [the moon](moon.png).`, and inside a link as plain text
- the link marks — unless the link's whole text is the image and the link stands alone in a
- plain paragraph, which reads as the image the link marks. To keep an image an image, give it
- a paragraph of its own: nothing else on its line, and a blank line between it and any text
- above or below. Images on consecutive lines share a paragraph.
+ - An image reads as an image only alone in its paragraph. Anywhere else — beside other text, in
+ a heading or a table cell, or in the paragraph an alert or task marker opens — it reads as its
+ alt text linked to its URL: `See .` reads as
+ `See [the moon](moon.png).`. To keep an image an image, give it a paragraph of its own: a
+ blank line between it and any other line.
+ - Inside a link, an image reads as plain text the link marks. A link alone in its paragraph
+ whose whole text is one image reads as that image, with the link marking it.
- An image's title becomes its caption when the image stays an image, and its link's title when
it becomes linked alt text. Inside another link, the title is dropped.
@@ -256,8 +256,8 @@ Serves Goals 1, 4 and 5.
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.
+ other. `corpus/commonmark-spec/exceptions.json` pins the first four `node-model` where the suite
+ reaches them, 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 c4c6a12..db1a2f7 100644
--- a/corpus/README.md
+++ b/corpus/README.md
@@ -17,8 +17,7 @@ One directory per contract kind:
`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-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).
+ CommonMark renders) 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/docs/decisions.md b/docs/decisions.md
index 329beea..1846167 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -202,19 +202,20 @@ link reference definition — and a directive link CommonMark could spell is ref
link — the bracket form goes literal, the directive form refused — which is CommonMark's prose
where its reference implementation nests one `` in another.
-## Every CommonMark image and empty link reads: the image where it stands alone, else a link, `[]()` as nothing
+## An image reads as an image only alone in its paragraph, else as its alt text linked; an empty link text reads as its destination
-2026-10-04, the maintainer and a writer panel, 3 of 3; `[]()` 2026-10-09, settled by Goals 4 and 7
-as the maintainer's plan item directed. Goals 4, 5, 6 and 7. Valid while `mediaInline` carries no
-URL and ADF holds no empty text node.
+2026-10-04, the maintainer and a writer panel, 3 of 3; `[]()` 2026-10-09, by Goals 4 and 7, as the
+maintainer's plan item asked. Goals 4, 5, 6 and 7. Valid while `mediaInline` carries no URL and ADF
+holds no empty text node.
-Every reader reads every CommonMark image and empty link. An image anywhere but alone in a plain
-paragraph reads as its alt text linked to its URL, titled by its title; an empty alt text reads as
-the URL, and an empty link text as its destination. `[]()` reads as nothing: it renders nothing, so
-Goal 7 loses no content. A titled image alone in a plain paragraph reads as the image captioned with
-its title. A link alone in a plain paragraph whose whole text is an image reads as the image with
-the link marking its `media`. Inside a link otherwise, the alt text is plain text the link marks and
-the image's URL and title drop: the panel chose this by Goal 6, which outranks Goal 7.
+Every reader reads every CommonMark image and empty link, as `spec/flavour.md` §The CommonMark
+image and §Marks spell out. An image alone in its paragraph reads as the image, its title as the caption.
+Anywhere else it reads as its alt text linked to its URL, titled by its title. An empty alt text
+reads as the URL, and an empty link text as its destination. `[]()` reads as nothing: it renders
+nothing, so Goal 7 loses no content. A link alone in its paragraph whose whole text is one image
+reads as that image, with the link marking its `media`. Inside any other link, the alt text is plain
+text the link marks, and the image's URL and title drop: the panel chose this by Goal 6, which
+outranks Goal 7.
## Ids stay site-local
diff --git a/spec/flavour.md b/spec/flavour.md
index b757532..5ebb2af 100644
--- a/spec/flavour.md
+++ b/spec/flavour.md
@@ -314,18 +314,18 @@ the destination, `alt` the description's plain-text content when non-empty — a
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. 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
+`[](href)`, marks the `media`. `adfToLosslessMarkdown` emits the image form only for the
+bare 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.
-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, the alt text is plain text the link
-marks, and the image's destination and title drop (`docs/decisions.md` §Every CommonMark image and
-empty link reads).
+`mediaInline` carries a media `collection` + `id`, never a URL, so anywhere else an image reads as
+its alt text linked to its destination, the link titled by the image's title: beside other
+content, in a heading, a table cell or an `!adf:paragraph`, and in the paragraph a portable alert,
+callout or task marker opens. An empty alt text reads as the destination, as an empty link text
+does. Inside a link's text, the alt text is plain text the link marks, and the image's destination
+and title drop (`docs/decisions.md` §An image reads as an image only alone in its paragraph).
### Tables
diff --git a/src/conformance/commonmark-spec.test.ts b/src/conformance/commonmark-spec.test.ts
index 1d701fc..6c81a49 100644
--- a/src/conformance/commonmark-spec.test.ts
+++ b/src/conformance/commonmark-spec.test.ts
@@ -92,7 +92,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 node it touches (docs/decisions.md §No schema validation).
+// A mark counts once per node carrying it: ADF marks each node where HTML nests one element (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',
diff --git a/src/markdown/external-image.ts b/src/markdown/external-image.ts
index 348e0d2..38a122b 100644
--- a/src/markdown/external-image.ts
+++ b/src/markdown/external-image.ts
@@ -1,6 +1,6 @@
import type { AdfNode } from '../adf/document.ts'
-// spec/flavour.md, The CommonMark image: `` reads to `centeredImage([externalMedia(alt, url)])`, the only shape written back as it.
+// spec/flavour.md, The CommonMark image: `` reads to `centeredImage([externalMedia(alt, url)])`, the only shape `adfToLosslessMarkdown` writes as ``.
export function centeredImage(content: AdfNode[]): AdfNode {
return { attrs: { layout: 'center' }, content, type: 'mediaSingle' }
}
diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts
index a25d06a..020685e 100644
--- a/src/markdown/parse/inline-content.ts
+++ b/src/markdown/parse/inline-content.ts
@@ -22,7 +22,7 @@ import { readDirectiveMark } from './directive-marks.ts'
import { readInlineDirectiveNode } from './directive-nodes.ts'
import { readTextDirective } from '../text-directive.ts'
-// `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.
+// Only a `paragraph` block takes `image`, the `mediaSingle` a lone image builds; every other container takes `nodes`, where the image is 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.
diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts
index c522e26..ff6a73e 100644
--- a/src/markdown/parse/markdown-to-adf.ts
+++ b/src/markdown/parse/markdown-to-adf.ts
@@ -318,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 a lone image the block it needs.
+// spec/flavour.md, The CommonMark image: only a `paragraph` block 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
diff --git a/todo.md b/todo.md
index dcf479d..3c57862 100644
--- a/todo.md
+++ b/todo.md
@@ -28,7 +28,7 @@
| 87 | 0.2.0 | defect | **Build a `hardBreak` carrying no marks where emphasis or a link spans a line break, in every reader.** | 4 | 3 | 6 | 7 | 4 | 16.6 |
| 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 |
+| 86 | 0.2.0 | | **Write an image whose `media` only a link marks, and one captioned with plain text, in the CommonMark spelling a writer panel picks, 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` to `result.ts`, and move `Flavour` and `flavourClaims` to `markdown/flavour.ts`.** | 3 | 5 | 2 | 6 | 2 | 10.4 |
@@ -81,7 +81,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 "Six CommonMark spellings" bullet, which counts one fewer.
+spelling leaves the README's "Seven 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.
@@ -91,11 +91,12 @@ 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`.
+### 86. Write an image whose `media` only a link marks, and one captioned with plain text, in the CommonMark spelling a writer panel picks, 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.
+Every reader reads `[](href)` and `` as these shapes, and
+`adfToLosslessMarkdown` writes the `!adf:mediaSingle` directive for both. The emitted form is API
+(`docs/decisions.md` §The formats are API): changing it inside `0.2.0` spares consumers a second
+breaking change to stored markdown.
### 85. Write an external image a link marks as `[](href)` in `adfToPortableMarkdown`, keeping the link target.
@@ -248,8 +249,8 @@ 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 "Six CommonMark
-spellings" bullet, which counts one fewer.
+gains its row. Its `pending` exceptions go, and its spelling leaves the README's "Seven
+CommonMark spellings" bullet, which counts one fewer.
### 53. Put a block's `marks` spelling to the writer panel and adopt its pick.
@@ -324,9 +325,8 @@ functions write `pending`, `pieces`, `deactivatedBefore` and `openingSpellableLi
`takeFallback` (`emit/inline-line.ts`), which writes the record the next `lineSegments` pass reads;
`writeUnpaired`, `markPairings` and `markHighlights` (`inline-content.ts`), whose order changes the
output; and `nestIn` (`portable/adf-to-portable-markdown.ts`), which pushes into its caller's array.
-Once `Scan`'s pieces have owners, the bracket resolution — `closeBracket` through `closeImage`,
-`linkPiece`, `linkTo`, `linkedAlt`, `mediaSingle`, `linkMark`, `holdsLink`, `imageAlt` and
-`altText` — can leave `inline-content.ts`, about 690 lines over five concerns, for a module of its own.
+Once `Scan`'s pieces have owners, the bracket resolution, `closeBracket` through `altText`, can
+leave `inline-content.ts` (about 690 lines) for a module of its own.
### 76. State the files the package does not ship once, so the build, the lint ceiling and the coverage exclusions cannot drift apart.