3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Answer prose round 1: plainer decision title and spec, the refusal the spec owes, and comments that say why

This commit is contained in:
2026-10-09 11:50:23 +02:00
parent ce827fe7bf
commit 6f1a16b04a
12 changed files with 40 additions and 41 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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 (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them |
| markdown holding no block (`""`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former |
| a 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 |
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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."
}
]
+6 -7
View File
@@ -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 `<a>` 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
+18 -18
View File
@@ -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 — `<url>` for a bare autolink-shaped text, else `[text](url "title")` — and is
the inline directive `!adf:link[text]{attrs}` only where CommonMark does not: an attribute besides
`href` and `title`, an `href` or `title` no canonical escape spells (a control character, a
backslash, an entity reference, an angle bracket beside a space or opening a bare destination, a
newline in the title), or a link opening a paragraph whose markdown spelling would read as a link
reference definition. 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 — `<url>` for a bare autolink-shaped text, else `[text](url
"title")` — and is the inline directive `!adf:link[text]{attrs}` only where CommonMark does not: an
attribute besides `href` and `title`, an `href` or `title` no canonical escape spells (a control
character, a backslash, an entity reference, an angle bracket beside a space or opening a bare
destination, a newline in the title), or a link opening a paragraph whose markdown spelling would
read as a link reference definition. 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
+1 -1
View File
@@ -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' }))),
+4 -4
View File
@@ -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<number>, 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}`)] })
}
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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))
+1 -1
View File
@@ -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`.