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 - CommonMark is a subset
- Tables - Tables
- Links - 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 - Ids stay site-local
- Portable task ids come from position - Portable task ids come from position
- A callout title keeps its link targets - 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 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 `losslessMarkdownToAdf` refuses a directive mark wrapping only hard breaks as
`unsupported-node-shape`. `adfToLosslessMarkdown` writes a hard break holding marks 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 `!adf:carry{json="…"}`, and spells an emphasis or link across the hard breaks inside it once,
it once, `_a\` then `b_`, where they closed and reopened it. See `MIGRATION.md`. `_a\` then `b_`, where `adfToMarkdown` closed and reopened it. See `MIGRATION.md`.
- Fix a cyclic document, in which an object holds itself: `adfToLosslessMarkdown` and - Fix a cyclic document, in which an object holds itself: `adfToLosslessMarkdown` and
`adfToPortableMarkdown` refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, `adfToPortableMarkdown` refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`,
where all three hung. 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 { adfToLosslessMarkdown, type AdfNode } from '@larvit/adf-codec'
import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1' 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 { function unmarkHardBreaks(node: AdfNode): AdfNode {
const { marks, ...unmarked } = node const { marks, ...unmarked } = node
if (node.type === 'hardBreak') return unmarked 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 | | 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 | | 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 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 | | 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 | | 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-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 ## The guarantees
+2 -2
View File
@@ -466,13 +466,13 @@
"divergence": "em 1/2", "divergence": "em 1/2",
"example": 638, "example": 638,
"kind": "mark-model", "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", "check": "count",
"divergence": "em 1/2", "divergence": "em 1/2",
"example": 639, "example": 639,
"kind": "mark-model", "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 link — the bracket form goes literal, the directive form refused — which is CommonMark's prose
where its reference implementation nests one `<a>` in another. 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 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 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
reader panel split 4 to 3. Goals 4, 5, 6 and 7. Valid while `mediaInline` carries no URL, ADF holds `mediaInline` carries no URL, ADF holds no empty text node, a hard break takes no marks and no
no empty text node and a hard break takes no marks. 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 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. 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 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, 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 reads as its destination, then those breaks: four of seven readers expected that over dropping them.
expected over dropping them, and the maintainer kept as an edge case until real reports arrive.
`[]()` reads as nothing: it renders nothing, so Goal 7 loses no content. A link alone in its `[]()` 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`. 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 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 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 `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` directive names: `!adf:em[x]` is a named error. `border`, `subsup`, `textColor` and `underline` are
are inline directives, content required non-empty. `link` keeps its markdown spelling wherever inline directives, content required, holding more than hard breaks. `link` keeps its markdown
CommonMark holds it — `<url>` for a bare autolink-shaped text, else `[text](url "title")` — and is spelling wherever CommonMark holds it — `<url>` for a bare autolink-shaped text, else `[text](url
the inline directive `!adf:link[text]{attrs}` only where CommonMark does not: an attribute besides "title")` — and is the inline directive `!adf:link[text]{attrs}` only where CommonMark does not: an
`href` and `title`, an `href` or `title` no canonical escape spells (a control character, a attribute besides `href` and `title`, an `href` or `title` no canonical escape spells (a control
backslash, an entity reference, an angle bracket beside a space or opening a bare destination, a character, a backslash, an entity reference, an angle bracket beside a space or opening a bare
newline in the title), or a link opening a paragraph whose markdown spelling would read as a link destination, a newline in the title), or a link opening a paragraph whose markdown spelling would
reference definition. Every such spelling carries an `href`: a directive link CommonMark could spell read as a link reference definition. Every such spelling carries an `href`: a directive link
is a named error, and so is one spelling none. No link wraps a link at any nesting, which is CommonMark could spell is a named error, and so is one spelling none. No link wraps a link at any
CommonMark's own rule: a `[text]` already holding one leaves the outer brackets literal text, and nesting, which is CommonMark's own rule: a `[text]` already holding one leaves the outer brackets
the directive form, open to no literal reading, is a named error. ADF holds no empty text node, so a literal text, and the directive form, open to no literal reading, is a named error. ADF holds no
link whose text is empty reads as its destination linked, `[](/url)` as `/url`, and one whose empty text node, so a link whose text is empty reads as its destination linked, `[](/url)` as
destination is empty too, `[]()`, as nothing. A link text holding only hard breaks reads the same, `/url`, and one whose destination is empty too, `[]()`, as nothing. A link text holding only hard
followed by its hard breaks. breaks reads the same, followed by its hard breaks.
- `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3). - `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3).
- `code`, `em`, `strike`, `strong` — Attributes: none. - `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 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 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 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 carrying an identical mark, attributes included, at that depth: `attrs: {}` differs from no `attrs`,
spanning the hard breaks between two of them: `attrs: {}` differs from no `attrs`, and a directive and a directive spells it `{attrs=empty}`. An emphasis or link spelling also spans the hard breaks
spells it `{attrs=empty}`. A run breaks at every node the emitter carries, so no emitted carry sits between two nodes of its run. A run breaks at every node the emitter carries, so no emitted carry
inside a mark spelling. sits inside a mark spelling.
An inline node whose marks no nesting spells — a hard break holding any mark, a mark type not listed 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 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') 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' } const link: AdfMark = { attrs: { href: 'https://example.com/' }, type: 'link' }
assert.equal( assert.equal(
readsBack(document(paragraph({ marks: [link], text: 'a', type: 'text' }, { marks: [link], type: 'hardBreak' }, { marks: [link], text: 'b', type: 'text' }))), 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 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 { function joinsRun(nodes: readonly AdfNode[], offset: number, firstIndex: number, run: MarkedRun, depth: number, context: InlineContext): boolean {
const kind = markSpelling(run.mark.type)?.kind const kind = markSpelling(run.mark.type)?.kind
if (kind !== 'emphasis' && kind !== 'link') return false 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) return node?.type === 'hardBreak' && !carries(node, context.carried, firstIndex + at) && !context.parted.has(firstIndex + at)
} }
if (!joinable(offset)) return false 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 if (run.nodes.at(-1)?.type === 'hardBreak') return true
let ahead = offset + 1 let ahead = offset + 1
while (joinable(ahead)) ahead += 1 while (joinable(ahead)) ahead += 1
@@ -259,7 +259,7 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath {
return [...context.path, 'content', index] 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 { function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean {
if (carried.has(index)) return true if (carried.has(index)) return true
if (node.type === 'text') return !isSpellableText(node) || typeof node.text !== 'string' || node.text === '' 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 }) const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true })
if (!inner.ok) return inner if (!inner.ok) return inner
if (inner.value.carry !== undefined) 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 }) 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}`)] }) 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' } 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[] { function linkTo(nodes: readonly Inline[], definition: LinkDefinition): Inline[] {
if (!nodes.every(takesNoMarks)) return applyMark(nodes, linkMark(definition)) if (!nodes.every(takesNoMarks)) return applyMark(nodes, linkMark(definition))
return definition.destination === '' ? [...nodes] : [{ marks: [linkMark(definition)], text: definition.destination, type: 'text' }, ...nodes] 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' })]) 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' }] const broken = (...marks: AdfMark[]): AdfNode[] => [{ content: [marked('a', ...marks), hardBreak(), marked('b', ...marks)], type: 'paragraph' }]
for (const read of [commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf]) { for (const read of [commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf]) {
assert.deepEqual(content(read('*a \nb*\n')), broken(em)) 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`. `*!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 `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. 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`. ### 33. Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.