diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb791de..17d7a2c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+- **Breaking:** `adfToHtml`, `adfToPortableMarkdown`, `htmlToAdf` and `losslessMarkdownToAdf` refuse
+ a block or non-text inline node holding `text`, and a leaf block or non-text inline node holding
+ `content`, as `adfToLosslessMarkdown` already did outside a carried node, which it now also
+ refuses. `MIGRATION.md` says how to convert a stored document.
- The package's description and keywords say it needs no React, Atlaskit or runtime dependency and
runs in Node, Forge, Deno, Bun and browsers, and the README opens with the call to take for each
need. The README's first comparison table lists the npm packages running in Node that convert ADF
diff --git a/MIGRATION.md b/MIGRATION.md
index 654a1d3..0a22b58 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -27,6 +27,13 @@ the error's `path` naming the card, since Atlassian's schema gives a card no lin
`link` mark from that card before you write it with `0.3.0`; to keep the link's target, put the
link on a `text` node beside the card.
+`0.3.0` refuses, as `unsupported-node-shape`, a block or non-text inline node holding `text`, and a
+leaf block or non-text inline node holding non-empty `content`. Atlassian's schema gives no node
+those. `adfToHtml` and `adfToPortableMarkdown` wrote such a node in `0.2.0`, and `adfToLosslessMarkdown`
+wrote one inside a carried node; all three now refuse it, and `losslessMarkdownToAdf` and `htmlToAdf`
+refuse a stored carry holding one. Read a stored document with `0.2.0`, remove what the error's
+`path` names and write it with `0.3.0`, once.
+
## From `0.1.0` to `0.2.0`
Each call names the markdown it reads or writes:
diff --git a/README.md b/README.md
index a4b8025..98d8f00 100644
--- a/README.md
+++ b/README.md
@@ -632,7 +632,7 @@ Every call can raise these:
- writes as a directive a node or mark the lossless flavour spells as CommonMark;
- puts `!adf:textBreak{}` where it parts nothing, or `!adf:doc` anywhere but as the whole
document;
- - carries an inline card a link marks, as the writers' cause below says.
+ - carries a node the writers' cause below names, such as an inline card a link marks.
- **Reading, in `htmlToAdf`.** Fix what the message names;
[`spec/html.md`](https://github.com/larvit/adf-codec/blob/main/spec/html.md) lists each element's
attributes. The HTML:
@@ -641,16 +641,17 @@ Every call can raise these:
`data-wrap="yes"`;
- holds a carry element whose `data-adf` holds JSON that is no ADF node, is not written compact
with its keys sorted, or holds a number JSON cannot spell;
- - holds a carry element carrying an inline card a link marks, as the writers' cause below says;
+ - holds a carry element carrying a node the writers' cause below names, such as an inline card a
+ link marks;
- holds the document's own carry element beside another block.
- **Writing, in `adfToLosslessMarkdown`**: 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`. Keep the ADF and show the document read-only; where you built the ADF, fix
+ twice. Keep the ADF and show the document read-only; where you built the ADF, fix
what the message names.
-- **Writing, in `adfToHtml`, `adfToLosslessMarkdown` and `adfToPortableMarkdown`**: an inline card
- marked by a link, even inside a node that rides the carry: Atlassian's schema gives a card no link
- mark. Keep the ADF and pass the document over or show it read-only; where you built the ADF,
- remove the link mark from the card the error's `path` names.
+- **Writing, in `adfToHtml`, `adfToLosslessMarkdown` and `adfToPortableMarkdown`**: what Atlassian's
+ schema gives no node, even inside a node that rides the carry: an inline card marked by a link, a
+ block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding
+ `content`. Keep the ADF and pass the document over or show it read-only; where you built the ADF,
+ remove what the error's `path` and message name.
- **Writing, in `adfToPortableMarkdown`**: a line or task list the portable writer cannot spell.
This is a bug in this library: report it.
- **Past the engine's longest string**:
diff --git a/docs/decisions.md b/docs/decisions.md
index 876ac7b..17002e7 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -436,8 +436,7 @@ the shown code keeps the class its code block's own element takes.
Inside a carry element, an image marked by a link to another target shows as portable markdown
writes it inline (§A linked image standing inline in portable markdown, its link to another target,
is written `[alt](href) ([image](url))`), so both targets stay; Goal 7 counts them as content. A link
-marking a node that shows a note goes around the note, and an image's own `text`, outside Atlassian's
-schema, shows first, as every carried node's does. Recorded 2026-10-24 without a panel of its own:
+marking a node that shows a note goes around the note. Recorded 2026-10-24 without a panel of its own:
it joins the second panel's `` to that portable spelling.
HTML nests no `` in an ``, so no element can hold both a card's URL and the target of a link
@@ -762,10 +761,8 @@ writer refuses alike, with one code, message and path, wherever the shape stands
too, and a carry reader refuses it with the same code and the writers' message after its holder's
name. The first step refuses an `inlineCard` with a `link` mark as `unsupported-node-shape`: the
schema gives a card the `annotation` mark alone (§The node tables answer to Atlassian's schema),
-and HTML nests no `` in an ``. `adfToLosslessMarkdown` still refuses two more, on a node not
-riding the carry: a block or non-text inline node holding `text`, and a leaf block or non-text
-inline node holding a non-empty `content`. `todo.md` item 167 moves them, and until it ships this entry does
-not hold for them.
+and HTML nests no `` in an ``. It also refuses a block or non-text inline node holding `text`,
+and a leaf block or non-text inline node holding a non-empty `content`.
Nothing else joins the first step. Nothing refuses a node type, mark or attribute the schema does
not name (§Unknown nodes ride the carry), a known node where no section spells it, or a mark a node
diff --git a/docs/reading-cost.md b/docs/reading-cost.md
index 29efd84..c77123a 100644
--- a/docs/reading-cost.md
+++ b/docs/reading-cost.md
@@ -667,3 +667,20 @@ Read most (opens and searches):
- `src/adf/shown-text.ts` 3
- `src/html/parse/inline.ts` 3
- `src/html/parse/source.ts` 3
+
+## Reading cost, chunk 6b022a1028b9, finished 2026-11-05T09:52:21Z
+
+253907 new input tokens before the first commit.
+
+Read most (opens and searches):
+
+- `ci.sh` 8
+- `docs/decisions.md` 8
+- `src/conformance/corpus.test.ts` 8
+- `todo.md` 8
+- `AGENTS.md` 6
+- `README.md` 5
+- `browser-tests/run.js` 4
+- `.gitignore` 3
+- `corpus/README.md` 3
+- `package.json` 3
diff --git a/spec/flavour.md b/spec/flavour.md
index 062a83c..7f7c7bb 100644
--- a/spec/flavour.md
+++ b/spec/flavour.md
@@ -582,9 +582,10 @@ open or close where the run sits (`un**-real**istic`), or one CommonMark's match
the merged run's pairing to another delimiter — rides the inline carry whole. So does every node
holding `em`, `strike` or `strong` in a line still read back wrong after 32 tries. An opaque carry inside
a mark spelling is a named error in input: the carry restores its node exactly, marks included
-(`docs/decisions.md` §Unknown nodes ride the carry). An inline card a link marks rides no carry:
-`adfToLosslessMarkdown` refuses it as `unsupported-node-shape`, and so does `losslessMarkdownToAdf`
-reading a carry that holds one, since Atlassian's schema gives a card no link mark.
+(`docs/decisions.md` §Unknown nodes ride the carry). An inline card a link marks, a block or non-text inline
+node holding `text`, and a leaf block or non-text inline node holding `content` ride no carry:
+`adfToLosslessMarkdown` refuses each as `unsupported-node-shape`, and so does `losslessMarkdownToAdf`
+reading a carry that holds one, since Atlassian's schema gives no node those.
```
!adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed].
diff --git a/spec/html.md b/spec/html.md
index 0789bba..457b84c 100644
--- a/spec/html.md
+++ b/spec/html.md
@@ -123,12 +123,13 @@ later release writes it as its own element.
alone whose `url` is missing or holds only whitespace;
- a code block holding more than one text node, a marked one, or a language holding ASCII
whitespace;
- - a rule or hard break holding `content`, and a hard break holding an attribute;
+ - a hard break holding an attribute;
- a list holding a node that is no `listItem`, or an item holding a mark, text, an empty key or
an attribute its type does not hold: the whole list rides the carry;
- a `listItem` outside a list, an inline node in block position and a block node inline.
-- An inline card a link marks is refused as `unsupported-node-shape`, carried or not, by `adfToHtml`
- and by `htmlToAdf` reading a carry element: Atlassian's schema gives a card no link mark.
+- An inline card a link marks, a block or non-text inline node holding `text`, and a leaf block or
+ non-text inline node holding `content` are refused as `unsupported-node-shape`, carried or not, by
+ `adfToHtml` and by `htmlToAdf` reading a carry element: Atlassian's schema gives no node those.
- A block carry's JSON nests at most 500 levels less the depth it stands at, an inline carry's at
most 500, as in markdown.
diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts
index ec3058c..88e6c4a 100644
--- a/src/adf/document.test.ts
+++ b/src/adf/document.test.ts
@@ -152,6 +152,41 @@ test('refuses an inline card a link marks, wherever the document holds it and wh
}
})
+test('refuses what Atlassian\'s schema gives no node, wherever the document holds it and whichever call writes it', () => {
+ const shapes: { message: string; node: AdfNode }[] = [
+ { message: 'a panel carries no text: this one holds text', node: { text: 'x', type: 'panel' } },
+ { message: 'a paragraph carries no text: this one holds text', node: { text: 'x', type: 'paragraph' } },
+ { message: 'a media holds no content: this one holds some', node: { content: [{ type: 'paragraph' }], type: 'media' } },
+ { message: 'a rule holds no content: this one holds some', node: { content: [{ type: 'paragraph' }], type: 'rule' } },
+ { message: 'a status node holds neither content nor text: this one holds content', node: { content: [{ text: 'x', type: 'text' }], type: 'status' } },
+ { message: 'a status node holds neither content nor text: this one holds text', node: { text: 'x', type: 'status' } },
+ { message: 'a hardBreak node holds neither content nor text: this one holds text', node: { text: 'x', type: 'hardBreak' } },
+ ]
+ for (const { message, node } of shapes) {
+ const holders = [
+ { content: [node], type: 'doc' },
+ { content: [{ content: [node], type: 'paragraph' }], type: 'doc' },
+ { content: [{ content: [node], type: 'futureBlock' }], type: 'doc' },
+ ]
+ const paths = [['content', 0], ['content', 0, 'content', 0], ['content', 0, 'content', 0]]
+ for (const [index, { content }] of holders.entries()) {
+ for (const write of [adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown]) {
+ const refused = write({ content, type: 'doc', version: 1 })
+ assert.deepEqual(refused.ok ? 'written' : refused.error, { code: 'unsupported-node-shape', message, path: paths[index] })
+ }
+ }
+ }
+})
+
+test('writes what the schema refusals leave to the carry', () => {
+ const carried: AdfNode[] = [
+ { content: [], type: 'rule' },
+ { content: [{ text: 'x', type: 'text' }], text: 'y', type: 'futureBlock' },
+ { content: [{ content: [{ type: 'paragraph' }], type: 'text' }], type: 'paragraph' },
+ ]
+ for (const node of carried) for (const write of [adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown]) assert.equal(write({ content: [node], type: 'doc', version: 1 }).ok, true)
+})
+
test('names the first link-marked card in document order', () => {
const card = (url: string): AdfNode => ({ attrs: { url }, marks: [{ attrs: { href: 'https://example.com/' }, type: 'link' }], type: 'inlineCard' })
const content = [{ content: [card('https://a.example/'), card('https://b.example/')], type: 'paragraph' }, { content: [card('https://c.example/')], type: 'paragraph' }]
diff --git a/src/adf/document.ts b/src/adf/document.ts
index eccc696..5120e0c 100644
--- a/src/adf/document.ts
+++ b/src/adf/document.ts
@@ -1,4 +1,6 @@
+import { blockNodeModel } from './block-nodes.ts'
import { failure, faulted, success, type ConvertError, type ConvertFault, type Result } from '../result.ts'
+import { inlineNodeModel } from './inline-nodes.ts'
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
import { largestNesting } from '../nesting.ts'
import { withinLongest } from '../length-bound.ts'
@@ -147,22 +149,36 @@ export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] {
return node.marks ?? []
}
-// Walks every node, carried or of an unknown type, so a link-marked card is refused wherever it stands (docs/decisions.md §Schema refusals are made in the first step alone).
+// Walks every node, carried or of an unknown type, so a shape is refused wherever it stands (docs/decisions.md §Schema refusals are made in the first step alone).
export function refusedShape(content: readonly AdfNode[]): ConvertError | undefined {
type Step = { index: number; node: AdfNode; parent: Step | undefined }
const pending: Step[] = content.map((node, index) => ({ index, node, parent: undefined })).reverse()
for (let step = pending.pop(); step !== undefined; step = pending.pop()) {
- if (step.node.type === 'inlineCard' && nodeMarks(step.node).some(({ type }) => type === 'link')) {
+ const message = shapeRefusal(step.node)
+ if (message !== undefined) {
const path: (number | string)[] = []
for (let up: Step | undefined = step; up !== undefined; up = up.parent) path.push(up.index, 'content')
path.reverse()
- return { code: 'unsupported-node-shape', message: 'an inlineCard takes no link mark: this one holds one', path }
+ return { code: 'unsupported-node-shape', message, path }
}
for (const child of nodeContent(step.node).map((node, index) => ({ index, node, parent: step })).reverse()) pending.push(child)
}
return undefined
}
+function shapeRefusal(node: AdfNode): string | undefined {
+ if (node.type === 'inlineCard' && nodeMarks(node).some(({ type }) => type === 'link')) return 'an inlineCard takes no link mark: this one holds one'
+ const holdsContent = nodeContent(node).length > 0
+ if (inlineNodeModel(node.type) !== undefined) {
+ if (holdsContent) return `a ${node.type} node holds neither content nor text: this one holds content`
+ return node.text === undefined ? undefined : `a ${node.type} node holds neither content nor text: this one holds text`
+ }
+ const model = blockNodeModel(node.type)
+ if (model === undefined) return undefined
+ if (node.text !== undefined) return `a ${node.type} carries no text: this one holds text`
+ return model.contentModel === 'none' && holdsContent ? `a ${node.type} holds no content: this one holds some` : undefined
+}
+
// Tracks ancestors alone: a tree may share an object, but an object holding itself walks the guards forever.
function holdsItself(root: object): boolean {
const pending = [{ depth: 0, item: root }]
diff --git a/src/adf/inline-nodes.ts b/src/adf/inline-nodes.ts
index 38322b5..a56eb9b 100644
--- a/src/adf/inline-nodes.ts
+++ b/src/adf/inline-nodes.ts
@@ -1,6 +1,5 @@
import type { AdfNode } from './document.ts'
import type { AttributeVocabulary } from './attribute-vocabulary.ts'
-import { nodeMarks } from './document.ts'
export const codeTextMarkTypes: readonly string[] = ['annotation', 'code', 'link']
export const formattedTextMarkTypes: readonly string[] = ['annotation', 'backgroundColor', 'em', 'link', 'strike', 'strong', 'subsup', 'textColor', 'underline']
@@ -40,7 +39,7 @@ export function inlineNodeModel(type: string): InlineNodeModel | undefined {
// Undefined means every mark: a node type outside this module's tables, which the schema restricts nowhere.
export function takenMarkTypes(node: AdfNode): readonly string[] | undefined {
- if (node.type === 'text') return nodeMarks(node).some((mark) => mark.type === 'code') ? codeTextMarkTypes : formattedTextMarkTypes
+ if (node.type === 'text') return node.marks?.some((mark) => mark.type === 'code') === true ? codeTextMarkTypes : formattedTextMarkTypes
return inlineNodeModel(node.type)?.markTypes
}
diff --git a/src/html/carry-content.ts b/src/html/carry-content.ts
index 1e2cd52..1207505 100644
--- a/src/html/carry-content.ts
+++ b/src/html/carry-content.ts
@@ -44,7 +44,7 @@ function shownPiece(node: AdfNode, lineFeed: string): string {
return linkedText(node, shownText(node), shownUrl(node), lineFeed)
}
-// As portable markdown writes it inline, except that a link around a note stays and an image's own `text`, outside ADF's schema, shows first (docs/decisions.md §The HTML dialect).
+// As portable markdown writes it inline, except that a link around a note stays (docs/decisions.md §The HTML dialect).
function linkedImage(media: AdfNode, lineFeed: string): string {
const image = shownImage(media, shownLink(media)?.href)
const shown = linkedText(media, image?.text, image?.link, lineFeed)
diff --git a/src/html/emit/adf-to-html.test.ts b/src/html/emit/adf-to-html.test.ts
index 5c82dd7..44bead3 100644
--- a/src/html/emit/adf-to-html.test.ts
+++ b/src/html/emit/adf-to-html.test.ts
@@ -163,7 +163,6 @@ test('carries an emoji, mention, status, date or card holding what its element c
assert.equal(carried({ attrs: { size: 'big', text: '🎉' }, type: 'emoji' }), true)
assert.equal(carried({ attrs: { id: 5, text: '🎉' }, type: 'emoji' }), true)
assert.equal(carried({ attrs: { text: '🎉' }, content: [], type: 'emoji' }), true)
- assert.equal(carried({ attrs: { text: '🎉' }, content: [text('x')], type: 'emoji' }), true)
assert.equal(carried({ attrs: { color: 'red' }, type: 'status' }), true)
assert.equal(carried({ attrs: { timestamp: 'soon' }, type: 'date' }), true)
assert.equal(carried({ attrs: { timestamp: 1756080000000 }, type: 'date' }), true)
@@ -184,7 +183,6 @@ test('carries an emoji, mention, status, date or card holding what its element c
assert.equal(carried({ attrs: { data: { name: 'Roadmap\u0001', url: 'https://example.com/' } }, type: 'inlineCard' }), true)
assert.equal(carried({ attrs: { id: '5b10a2' }, type: 'mention' }), true)
assert.equal(carried({ attrs: { id: '5b10a2', text: '' }, type: 'mention' }), true)
- assert.equal(carried({ attrs: { id: '5b10a2', text: '@Mikael' }, content: [text('x')], type: 'mention' }), true)
assert.equal(carried({ attrs: { id: '5b10a2', text: '@Mikael' }, marks: [comment], type: 'mention' }), true)
assert.equal(carried({ attrs: { id: 7, text: '@Mikael' }, type: 'mention' }), true)
assert.equal(carried({ attrs: { id: '5b10a2', text: 7 }, type: 'mention' }), true)
@@ -233,7 +231,6 @@ test('shows a code block in a div carry as pre and code, keeping its line feeds
test('shows a block inside a code block in a div carry as a paragraph, and the text after it as code', () => {
assert.equal(shown(html(adfToHtml(document({ content: [text('x'), paragraph(text('a\nb')), text('z')], type: 'codeBlock' })))), ' a ax
b
')
- assert.equal(shown(html(adfToHtml(document({ content: [text('x'), { attrs: { id: 'u' }, content: [paragraph(text('a'))], type: 'mention' }, text('z')], type: 'codeBlock' })))), 'zx@u
')
})
test('gives a code block in a div carry the class its element takes, and none where its language is no class token', () => {
@@ -270,8 +267,6 @@ test('shows a carried image marked by a link to another target as that link, the
assert.equal(image({ alt: 'The moon', type: 'external', url: 'https://example.com/' }), '')
const blank = shown(html(adfToHtml(document({ attrs: { layout: 'wide' }, content: [{ attrs: { type: 'external', url: 'https://example.com/moon.jpg' }, marks: [{ attrs: { href: ' ' }, type: 'link' }], type: 'media' }], type: 'mediaSingle' }))))
assert.equal(blank, '')
- const texted = shown(html(adfToHtml(document({ attrs: { layout: 'wide' }, content: [{ attrs: { alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }, marks: [link], text: 'Moon', type: 'media' }], type: 'mediaSingle' }))))
- assert.equal(texted, '')
const inline = (mark: AdfMark): string => shown(html(adfToHtml(document(paragraph({ attrs: { alt: 'The moon', type: 'external', url: 'https://example.com/moon.jpg' }, marks: [mark], type: 'mediaInline' })))))
assert.equal(inline(link), 'The moon (image)')
assert.equal(inline({ attrs: { href: 'https://example.com/', title: 5 }, type: 'link' }), 'The moon (image)')
@@ -308,7 +303,6 @@ test('carries a node holding what its element cannot', () => {
assert.equal(carriedBlock({ attrs: { layout: 'wide' }, content: [text('x')], type: 'paragraph' }), true)
assert.equal(carriedBlock({ content: [], type: 'paragraph' }), true)
assert.equal(carriedBlock({ attrs: {}, type: 'rule' }), true)
- assert.equal(carriedBlock({ content: [text('x')], type: 'rule' }), true)
assert.equal(carriedBlock({ attrs: { color: 'a\u0001' }, type: 'rule' }), true)
assert.equal(carriedBlock({ attrs: { level: 1 }, type: 'rule' }), true)
assert.equal(carriedBlock({ type: 'unknownBlock' }), true)
@@ -346,7 +340,6 @@ test('carries inline what its element cannot hold, and a text node that would jo
assert.equal(carriedSpans(text('x', { attrs: { href: 'https://example.com/' }, type: 'underline' })), 1)
assert.equal(carriedSpans(text('x', { attrs: { href: 'https://example.com/', id: ids.item }, type: 'link' })), 1)
assert.equal(carriedSpans({ attrs: { text: '\n' }, type: 'hardBreak' }), 1)
- assert.equal(carriedSpans({ content: [text('x')], type: 'hardBreak' }), 1)
assert.equal(carriedSpans(paragraph(text('x'))), 1)
})
diff --git a/src/html/emit/adf-to-html.ts b/src/html/emit/adf-to-html.ts
index 1907b6f..aa155ee 100644
--- a/src/html/emit/adf-to-html.ts
+++ b/src/html/emit/adf-to-html.ts
@@ -155,6 +155,6 @@ function tryImage(node: AdfNode): string | undefined {
}
function tryRule(node: AdfNode): string | undefined {
- const attributes = node.type === 'rule' && nodeContent(node).length === 0 ? dataAttributes(node, blockNodes.rule.attributes, []) : undefined
+ const attributes = node.type === 'rule' ? dataAttributes(node, blockNodes.rule.attributes, []) : undefined
return attributes === undefined ? undefined : `z
Check the collation.
')}\nAsk ${carry('span', mention, '@Mikael')}
\n`)), document(panel, paragraph(text('Ask '), mention))) assert.deepEqual(read(htmlToAdf(`${carry('span', text('a\u0085b', { type: 'underline' }), 'ab').replace('\u0085', '\\u0085')}
\n`)), document(paragraph(text('a\u0085b', { type: 'underline' })))) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index e9af1b5..178c120 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -174,8 +174,6 @@ function directivePair(node: AdfNode, opener: string, body: BlockText, headroom: } function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number, walkBody: () => Result