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

Give an inline node no mark Atlassian's schema withholds from it, in every reader

This commit is contained in:
2026-10-14 13:27:53 +02:00
committed by Lillem4n
parent dcbdf3683d
commit 1ae7ba6992
21 changed files with 122 additions and 102 deletions
+1 -1
View File
@@ -27,7 +27,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 no node that takes marks reads as its destination, then its hard breaks
- 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 a link reads as its destination, then those nodes
- A linked image is written `[![alt](url)](href)`, a captioned image as its directive
- Ids stay site-local
- Portable task ids come from position
+4
View File
@@ -4,6 +4,10 @@
- `losslessMarkdownToAdf` names the CommonMark spelling to write when it refuses a directive whose
node CommonMark spells, such as `[![alt](url)](href)` for a linked `!adf:mediaSingle`.
- `losslessMarkdownToAdf` gives a `date`, `emoji`, `inlineCard`, `mention`, `status` or
`mediaInline` no mark Atlassian's schema withholds from it: `**!adf:mention[@A]{id=…}**` reads as
a plain mention, and a link around only such nodes reads as its destination, then the nodes.
`adfToLosslessMarkdown` writes a node holding such a mark as `!adf:carry`, so it still round-trips.
## 0.2.0
+4 -2
View File
@@ -461,7 +461,8 @@ Every call can raise these:
one it needs;
- puts a line ending or a null character in an `emoji`, `mention` or `status` content slot:
replace a line ending with a space, or, for either, write the node as `!adf:carry`;
- wraps a directive mark around nothing or only hard breaks;
- wraps a directive mark around nothing, or only around nodes that take no such mark, such as a
hard break or a mention;
- 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.
@@ -504,7 +505,8 @@ Serves [Goals](#goals) 1, 4 and 5.
- These CommonMark spellings parse without an error but build a document the reference
implementation renders differently, because ADF holds no node for what the reference renders:
- An empty link text reads as its destination, `[](/url)` as `/url` linked to `/url`, and `[]()`
as nothing. A link text holding only hard breaks reads the same, followed by its hard breaks.
as nothing. A link text holding no node that takes a link, such as a hard break or a mention,
reads the same, followed by those nodes.
- 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 a task marker opens — it reads as its
alt text linked to its URL: `See ![the moon](moon.png).` reads as
@@ -24,7 +24,7 @@
"content": [
{
"attrs": {
"timestamp": "]:a"
"id": "]:a"
},
"marks": [
{
@@ -34,7 +34,7 @@
"type": "link"
}
],
"type": "date"
"type": "mediaInline"
}
],
"type": "paragraph"
@@ -1,3 +1,3 @@
!adf:link[`]: a`]{href="/u"}
!adf:link[!adf:date{timestamp="]:a"}]{href="/u"}
!adf:link[!adf:mediaInline{id="]:a"}]{href="/u"}
+3 -11
View File
@@ -19,14 +19,6 @@
"color": "red",
"text": "Blocked"
},
"marks": [
{
"attrs": {
"color": "#ae2e24"
},
"type": "textColor"
}
],
"type": "status"
},
{
@@ -107,8 +99,8 @@
},
{
"attrs": {
"shortName": ":rocket:",
"text": "🚀"
"collection": "contentId-98237",
"id": "01a032c3-7a90-70c9-88f6-c60f710eda07"
},
"marks": [
{
@@ -118,7 +110,7 @@
"type": "link"
}
],
"type": "emoji"
"type": "mediaInline"
}
],
"type": "paragraph"
+2 -2
View File
@@ -1,5 +1,5 @@
!adf:textColor[Overdue since !adf:status[Blocked]{color=red} on Tuesday]{color="#ae2e24"}
!adf:textColor[Overdue since ]{color="#ae2e24"}!adf:status[Blocked]{color=red}!adf:textColor[ on Tuesday]{color="#ae2e24"}
_Torque the **M8 bolt** to **25 Nm**_
[Release notes !adf:emoji[🚀]{shortName=":rocket:"}](https://example.com/release)
[Release notes !adf:mediaInline{collection=contentId-98237 id=01a032c3-7a90-70c9-88f6-c60f710eda07}](https://example.com/release)
@@ -1 +0,0 @@
**!adf:mention[@Mikael]{id=01a032c3-7a7c-775f-a730-2d79351338b4}** owns the rebuild.
@@ -60,11 +60,6 @@
"id": "01a032c3-7a7c-775f-a730-2d79351338b4",
"text": "@Mikael"
},
"marks": [
{
"type": "underline"
}
],
"type": "mention"
},
{
+1 -1
View File
@@ -1,3 +1,3 @@
!adf:textColor[Over]{color="#ae2e24"}!adf:textColor[due]{color="#a54800"} since the H!adf:subsup[2]{type=sub}O test, !adf:underline[signed by !adf:mention[@Mikael]{id=01a032c3-7a7c-775f-a730-2d79351338b4}] on the 2!adf:subsup[nd]{type=sup} of May.
!adf:textColor[Over]{color="#ae2e24"}!adf:textColor[due]{color="#a54800"} since the H!adf:subsup[2]{type=sub}O test, !adf:underline[signed by ]!adf:mention[@Mikael]{id=01a032c3-7a7c-775f-a730-2d79351338b4} on the 2!adf:subsup[nd]{type=sup} of May.
!adf:border[!adf:mediaInline{collection=contentId-98237 id=01a032c3-7a90-70c9-88f6-c60f710eda07}]{color="#091e42" size=2}
@@ -0,0 +1 @@
!adf:carry{json="{\"attrs\":{\"id\":\"01a032c3-7a7c-775f-a730-2d79351338b4\",\"text\":\"@Mikael\"},\"marks\":[{\"type\":\"strong\"}],\"type\":\"mention\"}"} owns the rebuild.
+7 -5
View File
@@ -228,18 +228,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 `<a>` in another.
## 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
## 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 a link reads as its destination, then those nodes
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. 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.
5, 6 and 7. Valid while `mediaInline` carries no URL, ADF holds no empty text node, Atlassian's
schema withholds the link from some inline nodes and no consumer has reported a link of such nodes
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
reads as its destination, then those breaks: four of seven readers expected that over dropping them.
reads as the URL, and an empty link text as its destination. A link text holding no node that takes
a link, such as a hard break or a mention, reads as its destination, then those nodes: four of seven
readers expected that of hard breaks 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
+9 -7
View File
@@ -519,7 +519,7 @@ 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, holding something besides hard breaks. `link` keeps its
inline directives, content required, holding a node that takes the mark. `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
@@ -530,8 +530,8 @@ CommonMark could spell is a named error, and so is one spelling none. No link wr
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.
`/url`, and one whose destination is empty too, `[]()`, as nothing. A link text holding no node
that takes a link reads the same, followed by its nodes.
- `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3).
- `code`, `em`, `strike`, `strong` — Attributes: none.
@@ -548,16 +548,18 @@ A block's `marks` key also holds these, which no inline spelling wraps:
- `fragment` — Attributes: `localId` (string), `name` (string).
- `indentation` — Attributes: `level` (number, 1–6).
A spelling adds its mark to every inline node it wraps but a hard break, which Atlassian's schema
gives no marks, and nesting is the marks array in order, outermost first: `_!adf:underline[x]_`
gives marks `[em, underline]`, `!adf:underline[_x_]` the reverse. `adfToLosslessMarkdown` nests in
A spelling adds its mark to every inline node it wraps but one Atlassian's schema withholds the mark
from: `hardBreak`, `date`, `emoji`, `inlineCard`, `mention` and `status` take none a spelling makes,
and `mediaInline` only `border` and `link`. Nesting is the marks array in order, outermost first:
`_!adf:underline[x]_` 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: `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.
An inline node whose marks no nesting spells — a hard break holding any mark, a mark type the first
An inline node whose marks no nesting spells — a node holding a mark Atlassian's schema withholds
from it, a mark type the first
list above does not hold, an attrs key its spelling does not list, a value that is not the
spelling's type, an attribute the spelling needs and the mark lacks, an empty `attrs` on a mark CommonMark spells, an order
putting a code span outside another mark, `code` over anything but a text node holding text, or over
+8 -8
View File
@@ -2,15 +2,15 @@ import type { AttributeVocabulary } from './attribute-vocabulary.ts'
export type InlineNodeModel = {
attributes: AttributeVocabulary
takesMarks: boolean
marks: readonly string[]
textAttribute?: string
}
export const inlineNodes: Readonly<Record<string, InlineNodeModel>> = {
date: { attributes: { localId: 'string', timestamp: 'string' }, takesMarks: true },
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, takesMarks: true, textAttribute: 'text' },
hardBreak: { attributes: { localId: 'string', text: 'string' }, takesMarks: false },
inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' }, takesMarks: true },
date: { attributes: { localId: 'string', timestamp: 'string' }, marks: ['annotation'] },
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, marks: ['annotation'], textAttribute: 'text' },
hardBreak: { attributes: { localId: 'string', text: 'string' }, marks: [] },
inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' }, marks: ['annotation'] },
mediaInline: {
attributes: {
alt: 'string',
@@ -23,10 +23,10 @@ export const inlineNodes: Readonly<Record<string, InlineNodeModel>> = {
type: 'string',
width: 'number',
},
takesMarks: true,
marks: ['annotation', 'border', 'dataConsumer', 'link'],
},
mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', text: 'string', userType: 'string' }, takesMarks: true, textAttribute: 'text' },
status: { attributes: { color: 'string', localId: 'string', style: 'string', text: 'string' }, takesMarks: true, textAttribute: 'text' },
mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', text: 'string', userType: 'string' }, marks: ['annotation'], textAttribute: 'text' },
status: { attributes: { color: 'string', localId: 'string', style: 'string', text: 'string' }, marks: ['annotation'], textAttribute: 'text' },
}
export function inlineNodeModel(type: string): InlineNodeModel | undefined {
+11 -5
View File
@@ -76,9 +76,9 @@ test("the ADF JSON Schemas hold no type the tables leave unspelled, the pinned c
assert.deepEqual(found, [])
})
test('the inline node table gives a type marks exactly where the ADF JSON Schemas give it a marks key', () => {
test('the inline node table gives a type exactly the mark types the ADF JSON Schemas give it, in order', () => {
const { marked } = schemaTypes()
for (const [type, model] of Object.entries(inlineNodes)) assert.equal(model.takesMarks, marked.has(type), type)
for (const [type, model] of Object.entries(inlineNodes)) assert.deepEqual(model.marks, [...(marked.get(type) ?? [])].sort(), type)
})
function spelled(): Spelled[] {
@@ -95,9 +95,9 @@ function spelledType(type: string, attributes: AttributeVocabulary, argument?: s
return [type, kinds]
}
function schemaTypes(): { held: Map<string, Held>; marked: Set<string> } {
function schemaTypes(): { held: Map<string, Held>; marked: Map<string, Set<string>> } {
const held = new Map<string, Held>()
const marked = new Set<string>()
const marked = new Map<string, Set<string>>()
for (const file of schemaFiles) {
const definitions = schemaObject(schemaObject(JSON.parse(readFileSync(join(root, file), 'utf8')), file)['definitions'], `${file} definitions`)
for (const [name, definition] of Object.entries(definitions)) {
@@ -107,7 +107,7 @@ function schemaTypes(): { held: Map<string, Held>; marked: Set<string> } {
for (const type of (properties.get('type') ?? []).flatMap((schema) => enumStrings(schema, `${where} type`))) {
const kinds = held.get(type) ?? new Map<string, Set<AttributeKind>>()
held.set(type, kinds)
if (properties.has('marks')) marked.add(type)
for (const marks of properties.get('marks') ?? []) marked.set(type, new Set([...(marked.get(type) ?? []), ...heldMarkTypes(marks, definitions, `${where} ${type}.marks`)]))
for (const [attribute, schemas] of attributes) {
const attributeKinds = kinds.get(attribute) ?? new Set<AttributeKind>()
kinds.set(attribute, attributeKinds)
@@ -120,6 +120,12 @@ function schemaTypes(): { held: Map<string, Held>; marked: Set<string> } {
return { held, marked }
}
function heldMarkTypes(marks: SchemaObject, definitions: SchemaObject, where: string): string[] {
if (marks['maxItems'] === 0) return []
if (marks['items'] === undefined) return ['every mark type']
return alternatives(schemaObject(marks['items'], `${where} items`), definitions, where).flatMap((properties) => (properties.get('type') ?? []).flatMap((schema) => enumStrings(schema, `${where} type`)))
}
function alternatives(schema: SchemaObject, definitions: SchemaObject, where: string): Properties[] {
const own = Object.entries(schemaObject(readSchema(schema, where)['properties'] ?? {}, `${where} properties`))
let found: Properties[] = [new Map(own.map(([name, property]): [string, SchemaObject[]] => [name, [readSchema(schemaObject(property, `${where} ${name}`), `${where} ${name}`)]]))]
@@ -284,6 +284,13 @@ test('carries a code mark over anything but text', () => {
)
})
test('carries an inline node holding a mark Atlassian\'s schema withholds from it', () => {
const carried = (node: AdfNode): string => markdown(adfToLosslessMarkdown(document(paragraph(node))))
assert.equal(carried({ attrs: { id: 'b1c2', text: '@A' }, marks: [{ type: 'strong' }], type: 'mention' }), '!adf:carry{json="{\\"attrs\\":{\\"id\\":\\"b1c2\\",\\"text\\":\\"@A\\"},\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"type\\":\\"mention\\"}"}\n')
assert.equal(carried({ attrs: { id: 'm1' }, marks: [{ type: 'em' }], type: 'mediaInline' }), '!adf:carry{json="{\\"attrs\\":{\\"id\\":\\"m1\\"},\\"marks\\":[{\\"type\\":\\"em\\"}],\\"type\\":\\"mediaInline\\"}"}\n')
assert.equal(carried({ attrs: { id: 'm1' }, marks: [{ attrs: { href: '/u' }, type: 'link' }], type: 'mediaInline' }), '[!adf:mediaInline{id=m1}](/u)\n')
})
test('spells a heading level no ATX heading fits as a directive', () => {
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { level: 7 }, content: [{ text: 'x', type: 'text' }], type: 'heading' }))), '!adf:heading {level=7}\nx\n!adf:/heading\n')
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'heading' }))), '!adf:heading\nx\n!adf:/heading\n')
+2 -2
View File
@@ -221,12 +221,12 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath {
return [...context.path, 'content', index]
}
// Decides the text-node, marked-hard-break and slot-fault carries up front, so a line holding many of them is emitted once.
// Decides the text-node, withheld-mark 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 === ''
const model = inlineNodeModel(node.type)
if (model === undefined || (!model.takesMarks && nodeMarks(node).length > 0)) return true
if (model === undefined || nodeMarks(node).some((mark) => !model.marks.includes(mark.type))) return true
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
return typeof slot === 'string' && slotFault(node.type, slot) !== undefined && nodeContent(node).length === 0 && node.text === undefined
}
+8 -7
View File
@@ -210,8 +210,8 @@ function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotC
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path)
}
if (slot.nodes.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
if (slot.nodes.every(takesNoMarks)) {
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps only hard breaks, which take no marks`, scan.path)
if (!slot.nodes.some((node) => takesMark(node, mark.type))) {
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps only nodes that take no ${name} mark`, scan.path)
}
const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, slot.nodes, index) : undefined
if (refused !== undefined) return refused
@@ -266,9 +266,9 @@ function mediaSingle({ alt, definition, link }: Image): AdfNode {
return centeredImage([externalMedia(alt, definition.destination, link), ...caption])
}
// ADF holds no empty text node and a hard break takes no marks, so the destination text carries the link ahead of the text's hard breaks; an empty destination leaves the breaks alone.
// ADF holds no empty text node and some nodes take no link, so the destination text carries the link ahead of the text's nodes; an empty destination leaves them alone.
function linkTo(nodes: readonly Inline[], definition: LinkDefinition): Inline[] {
if (!nodes.every(takesNoMarks)) return applyMark(nodes, linkMark(definition))
if (nodes.some((node) => takesMark(node, 'link'))) return applyMark(nodes, linkMark(definition))
return definition.destination === '' ? [...nodes] : [{ marks: [linkMark(definition)], text: definition.destination, type: 'text' }, ...nodes]
}
@@ -289,8 +289,9 @@ function partText(items: readonly Inline[], scan: Scan): Result<AdfNode[]> {
return success(parted)
}
function takesNoMarks(item: Inline): boolean {
return isNode(item) && inlineNodeModel(item.type)?.takesMarks === false
// spec/flavour.md, Marks: a spelling skips a node Atlassian's schema withholds its mark from.
function takesMark(item: Inline, type: string): boolean {
return !isNode(item) || (inlineNodeModel(item.type)?.marks.includes(type) ?? true)
}
function isNode(item: Inline): item is AdfNode {
@@ -590,7 +591,7 @@ function markType(character: string, used: number): string {
// `*(*a*)*` builds one `em`: a node holds at most one mark of each type.
function applyMark(nodes: readonly Inline[], mark: AdfMark): Inline[] {
return nodes.map((node) => {
if (!isNode(node) || takesNoMarks(node)) return node
if (!isNode(node) || !takesMark(node, mark.type)) return node
const marks = nodeMarks(node)
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
})
+25 -8
View File
@@ -909,6 +909,26 @@ test('gives the hard break inside a mark spelling no marks, and reads a link of
}
})
test('gives an inline node no mark Atlassian\'s schema withholds from it', () => {
const mention: AdfNode = { attrs: { id: 'b1c2', text: '@A' }, type: 'mention' }
const status: AdfNode = { attrs: { color: 'green', text: 'Done' }, type: 'status' }
const media: AdfNode = { attrs: { id: 'm1' }, type: 'mediaInline' }
const border: AdfMark = { attrs: { color: '#091e42', size: 2 }, type: 'border' }
const read = (markdown: string): AdfNode[] | string => content(losslessMarkdownToAdf(markdown))
assert.deepEqual(read('**!adf:mention[@A]{id=b1c2}**\n'), [{ content: [mention], type: 'paragraph' }])
assert.deepEqual(read('*a !adf:date{timestamp=1}*\n'), [{ content: [marked('a ', em), { attrs: { timestamp: '1' }, type: 'date' }], type: 'paragraph' }])
assert.deepEqual(read('!adf:underline[a !adf:emoji[🎉]{shortName=":tada:"}]\n'), [
{ content: [marked('a ', underline), { attrs: { shortName: ':tada:', text: '🎉' }, type: 'emoji' }], type: 'paragraph' },
])
assert.deepEqual(read('[!adf:status[Done]{color=green}](/u)\n'), [{ content: [marked('/u', link('/u')), status], type: 'paragraph' }])
assert.deepEqual(read('[a !adf:inlineCard{url="https://x/"}](/u)\n'), [{ content: [marked('a ', link('/u')), { attrs: { url: 'https://x/' }, type: 'inlineCard' }], type: 'paragraph' }])
assert.deepEqual(read('_!adf:mediaInline{id=m1}_\n'), [{ content: [media], type: 'paragraph' }])
assert.deepEqual(read('!adf:border[!adf:mediaInline{id=m1}]{color="#091e42" size=2}\n'), [{ content: [{ ...media, marks: [border] }], type: 'paragraph' }])
assert.deepEqual(read('[!adf:mediaInline{id=m1}](/u)\n'), [{ content: [{ ...media, marks: [link('/u')] }], type: 'paragraph' }])
assert.equal(read('!adf:underline[!adf:mention[@A]{id=b1c2}]\n'), 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps only nodes that take no underline mark')
assert.equal(read('!adf:link[!adf:status[Done]{color=green}]{collection=c href="/u"}\n'), 'unsupported-node-shape: the link mark wraps the [content] it marks: this one wraps only nodes that take no link mark')
})
test('decodes the fenced info string the block walk leaves raw', () => {
assert.deepEqual(content(losslessMarkdownToAdf('```java&#8203;script\nx\n```\n')), [
{ attrs: { language: 'java\u200bscript' }, content: [text('x')], type: 'codeBlock' },
@@ -1141,9 +1161,6 @@ test('reads the content slot as the text attribute the node spells there', () =>
assert.deepEqual(content(losslessMarkdownToAdf('!adf:status[ In review ]{color=yellow}\n')), status({ color: 'yellow', text: ' In review ' }))
assert.deepEqual(content(losslessMarkdownToAdf('!adf:status[In!adf:text{text=" "}review]{color=yellow}\n')), status({ color: 'yellow', text: 'In review' }))
assert.deepEqual(content(losslessMarkdownToAdf('!adf:status[a\\]b]{color=yellow}\n')), status({ color: 'yellow', text: 'a]b' }))
assert.deepEqual(content(losslessMarkdownToAdf('**!adf:mention[@A]{id=b1c2}**\n')), [
{ content: [{ attrs: { id: 'b1c2', text: '@A' }, marks: [strong], type: 'mention' }], type: 'paragraph' },
])
})
test('names the content slot no lone plain text node reads back from', () => {
@@ -1198,10 +1215,10 @@ test('reads the directive marks, the nesting outermost first', () => {
assert.deepEqual(content(losslessMarkdownToAdf('!adf:subsup[a]{type=sub}\n')), wrapped({ attrs: { type: 'sub' }, type: 'subsup' }))
assert.deepEqual(content(losslessMarkdownToAdf('!adf:border[a]{color="#091e42" size=2}\n')), wrapped({ attrs: { color: '#091e42', size: 2 }, type: 'border' }))
assert.deepEqual(content(losslessMarkdownToAdf('!adf:underline[a!adf:date{timestamp=1}]\n')), [
{ content: [marked('a', underline), { attrs: { timestamp: '1' }, marks: [underline], type: 'date' }], type: 'paragraph' },
{ content: [marked('a', underline), { attrs: { timestamp: '1' }, type: 'date' }], type: 'paragraph' },
])
assert.deepEqual(content(losslessMarkdownToAdf('_!adf:underline[a!adf:date{timestamp=1}]_\n')), [
{ content: [marked('a', em, underline), { attrs: { timestamp: '1' }, marks: [em, underline], type: 'date' }], type: 'paragraph' },
{ content: [marked('a', em, underline), { attrs: { timestamp: '1' }, type: 'date' }], type: 'paragraph' },
])
assert.equal(content(losslessMarkdownToAdf('!adf:border[a]{color="#091e42" size=x}\n')), 'unsupported-node-shape: the size attribute of border is no number')
})
@@ -1249,9 +1266,9 @@ test('names the directive mark left without the content it wraps', () => {
const named = 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps none'
assert.equal(content(losslessMarkdownToAdf('!adf:underline[]\n')), named)
assert.equal(content(losslessMarkdownToAdf('!adf:underline{}\n')), named)
const breaks = 'the [content] it marks: this one wraps only hard breaks, which take no marks'
assert.equal(content(losslessMarkdownToAdf('!adf:underline[!adf:hardBreak{}]\n')), `unsupported-node-shape: the underline mark wraps ${breaks}`)
assert.equal(content(losslessMarkdownToAdf('!adf:link[!adf:hardBreak{}]{href="/u" title=t}\n')), `unsupported-node-shape: the link mark wraps ${breaks}`)
const breaks = (name: string): string => `unsupported-node-shape: the ${name} mark wraps the [content] it marks: this one wraps only nodes that take no ${name} mark`
assert.equal(content(losslessMarkdownToAdf('!adf:underline[!adf:hardBreak{}]\n')), breaks('underline'))
assert.equal(content(losslessMarkdownToAdf('!adf:link[!adf:hardBreak{}]{href="/u" title=t}\n')), breaks('link'))
})
test('reads the text break only between two text nodes CommonMark joins, building no node', () => {
+26 -34
View File
@@ -25,9 +25,6 @@
| ID | Release | Exempt | Item | R | S | C | A | G | Goals | Score |
|---|---|---|---|---|---|---|---|---|---|---|
| 73 | 0.2.1 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 1 | 6 | 8 | 3 | 23.0 |
| 71 | 0.2.1 | | **Add a README table comparing the package with the other ADF converters, each cell checked against that package's own docs or behaviour.** | 1 | 3 | 2 | 6 | 7 | 3 | 21.2 |
| 89 | 0.2.1 | | **Build a `date`, `emoji`, `inlineCard`, `mention` or `status` holding no mark Atlassian's schema withholds from it, in every reader.** | 4 | 4 | 4 | 5 | 6 | 4 | 13.0 |
| 99 | 0.2.1 | defect | **Write a linked external image standing inline or in a table cell in the spelling a reader panel picks, in `adfToPortableMarkdown`.** | 3 | 3 | 5 | 6 | 7 | 7 | 12.9 |
| 100 | 0.2.1 | decision | **Put the reading of an image's title as its caption back to a reader panel, now that a writer panel reads a title as hover text.** | 3 | 3 | 3 | 4 | 5 | 4, 6 | 10.9 |
| 60 | 0.2.1 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 4 | 2 | 6 | 2 | 10.7 |
@@ -43,6 +40,7 @@
| 45 | 0.3.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 4 | 6 | 8 | 1 | 25.2 |
| 6 | 0.3.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 7 | 8 | 2, 4 | 24.7 |
| 77 | 0.3.0 | decision | **Read the input once into a plain copy, so an accessor property or a throwing Proxy yields `not-an-adf-document` and every call returns a result.** | 4 | 4 | 6 | 5 | 9 | 1 | 23.0 |
| 71 | 0.3.0 | | **Add a README table comparing the package with the other ADF converters, each cell checked against that package's own docs or behaviour.** | 1 | 3 | 2 | 6 | 7 | 3 | 21.2 |
| 80 | 0.3.0 | | **Name the node's path and the attribute key when `not-an-adf-document` refuses a node inside the document.** | 2 | 3 | 3 | 5 | 6 | 1 | 19.2 |
| 68 | 0.3.0 | | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 3 | 3 | 4 | 4 | 7 | 1 | 18.2 |
| 75 | 0.3.0 | | **Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.** | 2 | 2 | 4 | 3 | 7 | 1 | 17.5 |
@@ -54,6 +52,7 @@
| 51 | 0.3.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 4 | 5 | 2 | 7 | 4, 5 | 10.4 |
| 95 | 0.3.0 | decision | **Find the code-span closers an escape forms, the mark runs a line cannot spell and the text `[` that opens link text by reading the line back, in place of `escapeClosedRuns`, `unspellableRuns` and `lastLinkClose` predicting them.** | 5 | 6 | 9 | 2 | 6 | 1 | 9.5 |
| 91 | 0.3.0 | | **Spell an emphasis or link once across the hard breaks between two nodes it marks, in both writers.** | 5 | 5 | 8 | 5 | 6 | 6 | 9.0 |
| 73 | 0.4.0 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 1 | 6 | 8 | 3 | 23.0 |
| 9 | 0.4.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 4 | 6 | 8 | 3 | 21.0 |
| 38 | 0.4.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 5 | 4 | 7 | 1 | 19.5 |
| 74 | 0.4.0 | | **Keep the release path publishing after npm retires bypass-2FA tokens.** | 4 | 4 | 4 | 8 | 3 | 3 | 15.7 |
@@ -69,30 +68,6 @@
## Details
### 73. Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.
Lands after item 71, so each post links the GitHub repository, the README's comparison table and a
README that opens with what the package is. We draft each post for the place it goes; the maintainer
posts them once `0.2.1` is published. Found while planning discoverability, 2026-10-04.
### 71. Add a README table comparing the package with the other ADF converters, each cell checked against that package's own docs or behaviour.
Lands before items 7 and 46, which add HTML and a size column to the table. Its columns: which
directions each converts, whether the round-trip holds, what an unknown node does, the formats and
runtime dependencies. Candidates are the packages an npm search for ADF and markdown returns. Every
cell cites that package's README or a run against its published version, and the table names each
version and the date it was checked. Found while planning discoverability, 2026-10-04. Checked
2026-10-05: no other package converts HTML or holds the round-trip; the candidates are
`@atlaskit/editor-markdown-transformer`, `adf-to-markdown`, `adf-to-md`, `adf2markdown`,
`extended-markdown-adf-parser`, `marklassian` and `md-to-adf`.
### 89. Build a `date`, `emoji`, `inlineCard`, `mention` or `status` holding no mark Atlassian's schema withholds from it, in every reader.
`*!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. Replace `InlineNodeModel.takesMarks` (`src/adf/inline-nodes.ts`) with the
mark types a node takes, and have the emitter carry a node holding another.
### 99. Write a linked external image standing inline or in a table cell in the spelling a reader panel picks, in `adfToPortableMarkdown`.
`mediaLeaves` (`src/markdown/portable/inline-reduction.ts`) writes such an image `[alt](url)`, dropping
@@ -183,11 +158,11 @@ on such a document. Found while shipping item 106, 2026-10-11.
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.
Lands after items 6, 59, 60 and 62. Which flavours get a composed call is this item's to settle; the
CommonMark spec suite also runs against the one reading CommonMark. The README documents HTML as it
documents markdown, its comparison table (item 71) names HTML, and its tagline and `package.json`'s
description and keywords name HTML again. `htmlToAdf` refuses a value that is not a string as
`not-a-string` (`docs/decisions.md` §Which code a cause takes).
Lands after items 6, 59, 60 and 62. Which flavours get a composed call is this item's to settle;
the CommonMark spec suite also runs against the one reading CommonMark. The README documents HTML as
it documents markdown, and its tagline and `package.json`'s description and keywords name HTML
again. `htmlToAdf` refuses a value that is not a string as `not-a-string`
(`docs/decisions.md` §Which code a cause takes).
### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.
@@ -220,6 +195,17 @@ left, so a Proxy claiming 2³² elements is refused at once. The chunk asks whet
shares the 500-level guard's `unsupported-nesting-depth` or takes another code. Found by the
technical-principles audit, 2026-10-04.
### 71. Add a README table comparing the package with the other ADF converters, each cell checked against that package's own docs or behaviour.
Lands after items 7 and 46: the table names HTML and compares item 46's size figure. Its columns:
which directions each converts, whether the round-trip holds, what an unknown node does, the
formats, runtime dependencies and size. Candidates are the packages an npm search for ADF and
markdown returns. Every cell cites that package's README or a run against its published version, and
the table names each version and the date it was checked. Found while planning discoverability,
2026-10-04. Checked 2026-10-05: no other package converts HTML or holds the round-trip; the
candidates are `@atlaskit/editor-markdown-transformer`, `adf-to-markdown`, `adf-to-md`,
`adf2markdown`, `extended-markdown-adf-parser`, `marklassian` and `md-to-adf`.
### 80. Name the node's path and the attribute key when `not-an-adf-document` refuses a node inside the document.
Lands with items 45 and 77, whose reader walks every node. Today every refused node, such as one
@@ -266,8 +252,8 @@ tarball `npm pack` produces, its unpacked `dist`, and the built JavaScript minif
figure the competitors advertise (marklassian's "12kb") and the only apples-to-apples one, since
ours ships tsc's unminified output and no minifier yet (decide here whether to minify for the build
or report the unminified gzip). The figure lands in README §The package beside the "no runtime
dependencies" claim, and in a size column of the comparison table (item 71). Measured today,
unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6 kB.
dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6
kB.
### 72. Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.
@@ -312,6 +298,12 @@ typed across a line as two links. Joining the run across the break in `inline-li
with two comprehension panels (PR #22): a joined run that cannot be spelled needs its own fallback,
first as a set of parted breaks, then as a flag the first fallback clears.
### 73. Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.
Lands after `0.3.0` is published with item 71, so each post links the GitHub repository, the
README's comparison table and a README that opens with what the package is. We draft each post for
the place it goes; the maintainer posts them. Found while planning discoverability, 2026-10-04.
### 38. Spell a lone surrogate in a text node so it survives a UTF-8 encode.
`adfToLosslessMarkdown` emits it verbatim, so markdown stored as UTF-8 reads back U+FFFD; attribute