diff --git a/AGENTS.md b/AGENTS.md
index 8d23118..29d951b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,7 +25,7 @@ In `docs/decisions.md`:
- CommonMark is a subset
- Tables
- Links
-- An image reads as an image only alone in its paragraph, else as its alt text linked; a link text holding nothing a mark rides reads as its destination, then the hard breaks it held
+- An image reads as an image only alone in its paragraph, else as its alt text linked; a link text holding no node that takes marks reads as its destination, then its hard breaks
- 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 bf0f9df..6a54bfd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -75,8 +75,8 @@
whose text is only hard breaks reads as its destination linked, followed by those breaks, and
`losslessMarkdownToAdf` refuses a directive mark wrapping only hard breaks as
`unsupported-node-shape`. `adfToLosslessMarkdown` writes a hard break holding marks as
- `!adf:carry{json="…"}`, and both writers spell an emphasis or link across the hard breaks inside
- it once, `_a\` then `b_`, where they closed and reopened it. See `MIGRATION.md`.
+ `!adf:carry{json="…"}`, and spells an emphasis or link across the hard breaks inside it once,
+ `_a\` then `b_`, where `adfToMarkdown` closed and reopened it. See `MIGRATION.md`.
- Fix a cyclic document, in which an object holds itself: `adfToLosslessMarkdown` and
`adfToPortableMarkdown` refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`,
where all three hung.
diff --git a/MIGRATION.md b/MIGRATION.md
index e5f32ac..a33f04c 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -29,7 +29,7 @@ npm install @larvit/adf-codec@0.2.0 adf-codec-0.1@npm:@larvit/adf-codec@0.1.0
import { adfToLosslessMarkdown, type AdfNode } from '@larvit/adf-codec'
import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1'
-// 0.1.0 gave a hard break the marks spelled around it; Atlassian's schema gives it none.
+// 0.1.0 gave a hard break the marks spelled around it; 0.2.0 writes such a break as !adf:carry{…}.
function unmarkHardBreaks(node: AdfNode): AdfNode {
const { marks, ...unmarked } = node
if (node.type === 'hardBreak') return unmarked
@@ -72,7 +72,7 @@ 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 hard break inside a mark's spelling (`*a\` then `b*` on the next line) | the hard break holds the mark | the hard break holds no marks, as Atlassian's schema requires, and the recipe above drops them from stored markdown. A link whose text is only hard breaks reads as its destination linked, followed by those breaks |
+| a hard break inside a mark's spelling (`*a\` then `b*` on the next line) | the hard break holds the mark | the hard break holds no marks, as Atlassian's schema requires, and the recipe above drops the marks `0.1.0` gave it. A link whose text is only hard breaks reads as its destination linked, followed by those breaks |
| 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 |
diff --git a/README.md b/README.md
index fdcdaaf..eec4dac 100644
--- a/README.md
+++ b/README.md
@@ -215,7 +215,7 @@ emit refuses:
| Code | Fires when | What you can do |
| --- | --- | --- |
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
-| `unsupported-node-shape` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, wraps a directive mark around nothing or only hard breaks, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node carrying one mark type twice, unless it rides the carry, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content` | fix what the message names; `spec/flavour.md` lists every type's attributes and body |
+| `unsupported-node-shape` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, wraps a directive mark around nothing or only hard breaks, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node not riding the carry that carries one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content` | fix what the message names; `spec/flavour.md` lists every type's attributes and body |
## The guarantees
diff --git a/corpus/commonmark-spec/exceptions.json b/corpus/commonmark-spec/exceptions.json
index 9911f37..10d6435 100644
--- a/corpus/commonmark-spec/exceptions.json
+++ b/corpus/commonmark-spec/exceptions.json
@@ -466,13 +466,13 @@
"divergence": "em 1/2",
"example": 638,
"kind": "mark-model",
- "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
+ "reason": "The mark spans the text nodes either side of the hard break, counted once per node where CommonMark nests one element."
},
{
"check": "count",
"divergence": "em 1/2",
"example": 639,
"kind": "mark-model",
- "reason": "The mark spans adjacent text nodes, counted once per node where CommonMark nests one element."
+ "reason": "The mark spans the text nodes either side of the hard break, counted once per node where CommonMark nests one element."
}
]
diff --git a/docs/decisions.md b/docs/decisions.md
index 87aba83..c7146f6 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -202,19 +202,18 @@ 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.
-## An image reads as an image only alone in its paragraph, else as its alt text linked; a link text holding nothing a mark rides reads as its destination, then the hard breaks it held
+## An image reads as an image only alone in its paragraph, else as its alt text linked; a link text holding no node that takes marks reads as its destination, then its hard breaks
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; a link text of hard breaks alone 2026-10-09, the maintainer, after a
-reader panel split 4 to 3. Goals 4, 5, 6 and 7. Valid while `mediaInline` carries no URL, ADF holds
-no empty text node and a hard break takes no marks.
+maintainer's plan item asked; a link text of hard breaks alone 2026-10-09, the maintainer. Goals 4, 5, 6 and 7. Valid while
+`mediaInline` carries no URL, ADF holds no empty text node, a hard break takes no marks and no
+consumer has reported a link of hard breaks alone.
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; a link text holding only hard breaks,
-which take no marks, reads as its destination followed by those breaks, which four of seven readers
-expected over dropping them, and the maintainer kept as an edge case until real reports arrive.
+reads as the URL, and an empty link text as its destination. A link text holding only hard breaks
+reads as its destination, then those breaks: four of seven readers expected that over dropping them.
`[]()` 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
diff --git a/spec/flavour.md b/spec/flavour.md
index 007118c..2a4ef24 100644
--- a/spec/flavour.md
+++ b/spec/flavour.md
@@ -511,20 +511,20 @@ Hello, !adf:textBreak{}world — **Hello, !adf:textBreak{}world** — `a`!adf:te
An inline node's marks ride the spelling wrapped around them, never the block sections' reserved
`marks` key. `code`, `em`, `strike` and `strong` keep their markdown spellings, and are not
-directive names: `!adf:em[x]` is a named error. `border`, `subsup`, `textColor` and `underline`
-are inline directives, content required non-empty. `link` keeps its markdown spelling wherever
-CommonMark holds it — `` for a bare autolink-shaped text, else `[text](url "title")` — and is
-the inline directive `!adf:link[text]{attrs}` only where CommonMark does not: an attribute besides
-`href` and `title`, an `href` or `title` no canonical escape spells (a control character, a
-backslash, an entity reference, an angle bracket beside a space or opening a bare destination, a
-newline in the title), or a link opening a paragraph whose markdown spelling would read as a link
-reference definition. 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. 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. A link text holding only hard breaks reads the same,
-followed by its hard breaks.
+directive names: `!adf:em[x]` is a named error. `border`, `subsup`, `textColor` and `underline` are
+inline directives, content required, holding more than hard breaks. `link` keeps its markdown
+spelling wherever CommonMark holds it — `` for a bare autolink-shaped text, else `[text](url
+"title")` — and is the inline directive `!adf:link[text]{attrs}` only where CommonMark does not: an
+attribute besides `href` and `title`, an `href` or `title` no canonical escape spells (a control
+character, a backslash, an entity reference, an angle bracket beside a space or opening a bare
+destination, a newline in the title), or a link opening a paragraph whose markdown spelling would
+read as a link reference definition. 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. 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. A link text holding only hard
+breaks reads the same, followed by its hard breaks.
- `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3).
- `code`, `em`, `strike`, `strong` — Attributes: none.
@@ -539,10 +539,10 @@ gives no marks, and nesting is the marks array in order, outermost first: `_!adf
gives marks `[em, underline]`, `!adf:underline[_x_]` the reverse. `adfToLosslessMarkdown` nests in
the order the array holds rather than sorting it — `docs/decisions.md` §Equality is deep restores
the array, not a set — and opens each spelling once over the longest run of adjacent inline nodes
-carrying an identical mark, attributes included, at that depth, an emphasis or link spelling also
-spanning the hard breaks between two of them: `attrs: {}` differs from no `attrs`, and a directive
-spells it `{attrs=empty}`. A run breaks at every node the emitter carries, so no emitted carry sits
-inside a mark spelling.
+carrying an identical mark, attributes included, at that depth: `attrs: {}` differs from no `attrs`,
+and a directive spells it `{attrs=empty}`. An emphasis or link spelling also spans the hard breaks
+between two nodes of its run. A run breaks at every node the emitter carries, so no emitted carry
+sits inside a mark spelling.
An inline node whose marks no nesting spells — a hard break holding any mark, a mark type not listed
here, an attrs key its spelling does not list, a value that is not the spelling's type, an attribute
diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts
index 22bead4..820e5f2 100644
--- a/src/markdown/emit/adf-to-markdown.test.ts
+++ b/src/markdown/emit/adf-to-markdown.test.ts
@@ -728,7 +728,7 @@ test('spells a mark run across the hard break between two nodes holding the mark
assert.equal(readsBack(document(paragraph({ text: 'x', type: 'text' }, marked('.', em), hardBreak, marked('b', em)))), 'x!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\".\\",\\"type\\":\\"text\\"}"}\\\n_b_\n')
})
-test('carries a hard break holding marks, which Atlassian\'s schema gives it none of', () => {
+test('carries a hard break holding marks, which Atlassian\'s schema withholds', () => {
const link: AdfMark = { attrs: { href: 'https://example.com/' }, type: 'link' }
assert.equal(
readsBack(document(paragraph({ marks: [link], text: 'a', type: 'text' }, { marks: [link], type: 'hardBreak' }, { marks: [link], text: 'b', type: 'text' }))),
diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts
index f6f4b46..356c34d 100644
--- a/src/markdown/emit/inline-line.ts
+++ b/src/markdown/emit/inline-line.ts
@@ -230,7 +230,7 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
return runs
}
-// A directive's [content] holds no line ending, so only an emphasis or link spelling spans the hard breaks between two nodes it marks.
+// A directive's [content] holds no line ending, and `!adf:hardBreak{}` there reads worse than closing and reopening it, so only an emphasis or link spelling spans the hard breaks between two nodes it marks.
function joinsRun(nodes: readonly AdfNode[], offset: number, firstIndex: number, run: MarkedRun, depth: number, context: InlineContext): boolean {
const kind = markSpelling(run.mark.type)?.kind
if (kind !== 'emphasis' && kind !== 'link') return false
@@ -239,7 +239,7 @@ function joinsRun(nodes: readonly AdfNode[], offset: number, firstIndex: number,
return node?.type === 'hardBreak' && !carries(node, context.carried, firstIndex + at) && !context.parted.has(firstIndex + at)
}
if (!joinable(offset)) return false
- // A later break in a sequence joins because the first one found the node closing it.
+ // A later break in a sequence joins because the first one found the node closing the run.
if (run.nodes.at(-1)?.type === 'hardBreak') return true
let ahead = offset + 1
while (joinable(ahead)) ahead += 1
@@ -259,7 +259,7 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath {
return [...context.path, 'content', index]
}
-// Decides the text-node, mark and slot-fault carries up front, so a line holding many of them is emitted once.
+// Decides the text-node, marked-hard-break and slot-fault carries up front, so a line holding many of them is emitted once.
function carries(node: AdfNode, carried: ReadonlySet, index: number): boolean {
if (carried.has(index)) return true
if (node.type === 'text') return !isSpellableText(node) || typeof node.text !== 'string' || node.text === ''
@@ -327,7 +327,7 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number,
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true })
if (!inner.ok) return inner
if (inner.value.carry !== undefined) return inner
- // A link `tryLink` gave back holds the hard breaks `joinsRun` joined; the carry's fallback parts them.
+ // A link `tryLink` declined holds the hard breaks `joinsRun` joined; the carry's fallback parts them.
if (inner.value.segments.some((segment) => segment.text.includes('\n'))) return success({ carry: range })
return success({ segments: [syntax(spellInlineDirectiveOpener(mark.type)), ...inner.value.segments, syntax(`]${attributes}`)] })
}
diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts
index 5fb5f91..aaf26a9 100644
--- a/src/markdown/parse/inline-content.ts
+++ b/src/markdown/parse/inline-content.ts
@@ -325,7 +325,7 @@ 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 a link text holding nothing a mark rides reads as its destination, then the hard breaks it held, and an empty destination as those breaks alone.
+// ADF holds no empty text node and a hard break takes no marks, so the destination text carries the link.
function linkTo(nodes: readonly Inline[], definition: LinkDefinition): Inline[] {
if (!nodes.every(takesNoMarks)) return applyMark(nodes, linkMark(definition))
return definition.destination === '' ? [...nodes] : [{ marks: [linkMark(definition)], text: definition.destination, type: 'text' }, ...nodes]
diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts
index 74ca9ac..b284a07 100644
--- a/src/markdown/parse/markdown-to-adf.test.ts
+++ b/src/markdown/parse/markdown-to-adf.test.ts
@@ -828,7 +828,7 @@ test('reads a hard break from a trailing backslash and from two trailing spaces
assert.deepEqual(content(losslessMarkdownToAdf('> One\\\n> two.\n')), [quote({ content: [text('One'), hardBreak(), text('two.')], type: 'paragraph' })])
})
-test('builds the hard break a mark spelling spans with no marks, in every reader', () => {
+test('gives the hard break inside a mark spelling no marks, and reads a link of hard breaks as its destination, in every reader', () => {
const broken = (...marks: AdfMark[]): AdfNode[] => [{ content: [marked('a', ...marks), hardBreak(), marked('b', ...marks)], type: 'paragraph' }]
for (const read of [commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf]) {
assert.deepEqual(content(read('*a \nb*\n')), broken(em))
diff --git a/todo.md b/todo.md
index 27d37b1..fe27cfc 100644
--- a/todo.md
+++ b/todo.md
@@ -137,7 +137,7 @@ three directories.
`*!adf:mention[@a]{id=…}*` and `[!adf:status[Done]{…}](/u)` build a node holding `em` or `link`.
`full.json` gives these five no `marks`, and `stage-0.json` gives them `annotation` alone, so a site
-may reject the document. `InlineNodeModel.marks` (`src/adf/inline-nodes.ts`) then names the mark
+may reject the document. `InlineNodeModel.marks` (`src/adf/inline-nodes.ts`) afterwards names the mark
types a node takes, and the emitter carries a node holding another.
### 33. Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.