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

Answer architecture round 1: the node table says a hard break takes no marks, and a link text of hard breaks alone reads as its destination

This commit is contained in:
2026-10-09 11:04:30 +02:00
parent fe5c731b1f
commit 018c8b1ea7
16 changed files with 185 additions and 34 deletions
+4 -2
View File
@@ -72,8 +72,10 @@
([V8 bug](https://issues.chromium.org/issues/521080746)).
- **Breaking:** a hard break inside emphasis, strike, a link or a directive mark reads holding no
marks, as Atlassian's schema requires, where every reader gave it the marks around it.
`adfToLosslessMarkdown` writes a hard break holding marks as `!adf:carry{json="…"}`. See
`MIGRATION.md`.
A link whose text is only hard breaks reads as an empty link text does, as its destination
linked, 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="…"}`. 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.
+1 -1
View File
@@ -66,7 +66,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; converting per the recipe above keeps a stored one's marks in the inline carry |
| 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; converting per the recipe above keeps a stored one's marks in the inline carry. A link whose text is only a hard break reads as its destination linked |
| 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 |
+3 -3
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, 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, 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, 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 as a hard break holding marks or a type `spec/flavour.md` does not spell, 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
@@ -240,8 +240,8 @@ Serves Goals 1, 4 and 5.
canonical spelling, which round-trips byte-identically.
- Seven CommonMark spellings parse without an error and build a document the reference
implementation renders differently. Four because ADF holds no node for what it renders:
- An empty link text reads as its destination, `[](/url)` as `/url` linked to `/url`, and
`[]()` as nothing.
- An empty link text, or one holding only hard breaks, reads as its destination, `[](/url)` as
`/url` linked to `/url`, and `[]()` as nothing.
- 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
@@ -0,0 +1,64 @@
{
"content": [
{
"content": [
{
"marks": [
{
"type": "em"
}
],
"text": "One",
"type": "text"
},
{
"type": "hardBreak"
},
{
"marks": [
{
"type": "em"
}
],
"text": "Two",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"attrs": {
"href": "/u"
},
"type": "link"
}
],
"text": "Three",
"type": "text"
},
{
"type": "hardBreak"
},
{
"marks": [
{
"attrs": {
"href": "/u"
},
"type": "link"
}
],
"text": "Four",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,5 @@
*One\
Two*
[Three
Four](/u)
@@ -0,0 +1,37 @@
{
"content": [
{
"content": [
{
"marks": [
{
"type": "em"
}
],
"text": "One",
"type": "text"
},
{
"marks": [
{
"type": "em"
}
],
"type": "hardBreak"
},
{
"marks": [
{
"type": "em"
}
],
"text": "Two",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
_One_!adf:carry{json="{\"marks\":[{\"type\":\"em\"}],\"type\":\"hardBreak\"}"}_Two_
+9
View File
@@ -124,3 +124,12 @@ Inherited architect, decided by:
- `src/result.ts:1` `ConvertErrorCode`: one rule no longer enforced at five sites
- `src/markdown/parse/inline-content.ts:26` `InlineContent`: costs more — `contentNode` drops `image` silently
- `src/markdown/parse/inline-content.ts:302` `assemble`: costs more — three routes from an image piece to text
## 2026-10-09T09:00:13Z, PR #22 at fe5c731, against 78655bc
Ruling: same
| Seat | Vote |
|---|---|
| Mid A | same |
| Inherited architect | same |
+4 -3
View File
@@ -205,14 +205,15 @@ 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; an empty link text reads as its destination
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. Goals 4, 5, 6 and 7. Valid while `mediaInline` carries no URL and ADF
maintainer's plan item asked; a link text of hard breaks alone 2026-10-09, by Goal 7. Goals 4, 5, 6 and 7. Valid while `mediaInline` carries no URL and ADF
holds no empty text node.
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. `[]()` 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 the URL, and an empty link text as its destination; so does a link text holding only hard
breaks, which take no marks. `[]()` 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 drop: the panel chose this by Goal 6, which
outranks Goal 7.
+1 -1
View File
@@ -522,7 +522,7 @@ reference definition. Every such spelling carries an `href`: a directive link Co
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
a link whose text is empty or holds only hard breaks reads as its destination linked, `[](/url)` as `/url`, and one whose
destination is empty too, `[]()`, as nothing.
- `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3).
+8 -6
View File
@@ -2,14 +2,15 @@ import type { AttributeVocabulary } from './attribute-vocabulary.ts'
export type InlineNodeModel = {
attributes: AttributeVocabulary
marks: boolean
textAttribute?: string
}
export const inlineNodes: Readonly<Record<string, InlineNodeModel>> = {
date: { attributes: { localId: 'string', timestamp: 'string' } },
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, textAttribute: 'text' },
hardBreak: { attributes: { localId: 'string', text: 'string' } },
inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' } },
date: { attributes: { localId: 'string', timestamp: 'string' }, marks: true },
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, marks: true, textAttribute: 'text' },
hardBreak: { attributes: { localId: 'string', text: 'string' }, marks: false },
inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' }, marks: true },
mediaInline: {
attributes: {
alt: 'string',
@@ -22,9 +23,10 @@ export const inlineNodes: Readonly<Record<string, InlineNodeModel>> = {
type: 'string',
width: 'number',
},
marks: true,
},
mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', text: 'string', userType: 'string' }, textAttribute: 'text' },
status: { attributes: { color: 'string', localId: 'string', style: 'string', text: 'string' }, textAttribute: 'text' },
mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', text: 'string', userType: 'string' }, marks: true, textAttribute: 'text' },
status: { attributes: { color: 'string', localId: 'string', style: 'string', text: 'string' }, marks: true, textAttribute: 'text' },
}
export function inlineNodeModel(type: string): InlineNodeModel | undefined {
+11 -4
View File
@@ -31,7 +31,7 @@ test('the ADF JSON Schemas are @atlaskit/adf-schema 57.4.9, vendored byte-exact'
})
test('the tables spell the attribute names and kinds the ADF JSON Schemas give each type they spell, the pinned gaps apart', () => {
const held = schemaTypes()
const { held } = schemaTypes()
const spelledTypes = spelled()
const found: string[] = []
const gapsHeld = new Set<string>()
@@ -63,7 +63,7 @@ test('the tables spell the attribute names and kinds the ADF JSON Schemas give e
})
test("the ADF JSON Schemas hold no type the tables leave unspelled, the pinned carried ones and the grammar's own apart", () => {
const held = schemaTypes()
const { held } = schemaTypes()
const spelledNames = new Set(spelled().map(([type]) => type))
const found: string[] = []
for (const type of held.keys()) {
@@ -76,6 +76,11 @@ 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', () => {
const { marked } = schemaTypes()
for (const [type, model] of Object.entries(inlineNodes)) assert.equal(model.marks, marked.has(type), type)
})
function spelled(): Spelled[] {
return [
...Object.entries(blockNodes).map(([type, model]) => spelledType(type, model.attributes, blockArgument(type))),
@@ -90,8 +95,9 @@ function spelledType(type: string, attributes: AttributeVocabulary, argument?: s
return [type, kinds]
}
function schemaTypes(): Map<string, Held> {
function schemaTypes(): { held: Map<string, Held>; marked: Set<string> } {
const held = new Map<string, Held>()
const marked = new 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)) {
@@ -101,6 +107,7 @@ function schemaTypes(): Map<string, Held> {
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 [attribute, schemas] of attributes) {
const attributeKinds = kinds.get(attribute) ?? new Set<AttributeKind>()
kinds.set(attribute, attributeKinds)
@@ -110,7 +117,7 @@ function schemaTypes(): Map<string, Held> {
}
}
}
return held
return { held, marked }
}
function alternatives(schema: SchemaObject, definitions: SchemaObject, where: string): Properties[] {
+3 -4
View File
@@ -217,13 +217,12 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath {
return [...context.path, 'content', index]
}
// Decides the text-node and slot-fault carries up front, so a line holding many of them is emitted once.
// Decides the text-node, 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 === ''
if (node.type === 'hardBreak') return nodeMarks(node).length > 0
const model = inlineNodeModel(node.type)
if (model === undefined) return true
if (model === undefined || (!model.marks && nodeMarks(node).length > 0)) 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
}
@@ -283,7 +282,7 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number,
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined
if (link !== undefined) return link
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true })
if (!inner.ok) return inner
if (inner.value.carry !== undefined) return inner
return success({ segments: [syntax(spellInlineDirectiveOpener(mark.type)), ...inner.value.segments, syntax(`]${attributes}`)] })
+11 -7
View File
@@ -260,10 +260,10 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
}
function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result<Piece> {
if (slot === undefined || slot.nodes.length === 0) {
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path)
if (slot?.nodes.some(holdsOwnMarks) === true) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
if (slot === undefined || slot.nodes.every(takesNoMarks)) {
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps nothing a mark rides`, scan.path)
}
if (slot.nodes.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, slot.nodes, index) : undefined
if (refused !== undefined) return refused
return success({ kind: 'nodes', nodes: applyMark(slot.nodes, mark) })
@@ -322,9 +322,9 @@ 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 an empty link text reads as its destination, and an empty destination as nothing.
// 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, and an empty destination as nothing.
function linkTo(nodes: readonly Inline[], definition: LinkDefinition): Inline[] {
if (nodes.length > 0) return applyMark(nodes, linkMark(definition))
if (!nodes.every(takesNoMarks)) return applyMark(nodes, linkMark(definition))
return definition.destination === '' ? [] : [{ marks: [linkMark(definition)], text: definition.destination, type: 'text' }]
}
@@ -345,6 +345,10 @@ function partText(items: readonly Inline[], scan: Scan): Result<AdfNode[]> {
return success(parted)
}
function takesNoMarks(item: Inline): boolean {
return isNode(item) && inlineNodeModel(item.type)?.marks === false
}
function isNode(item: Inline): item is AdfNode {
return !('kind' in item)
}
@@ -669,10 +673,10 @@ function markType(character: string, used: number): string {
return used === 2 ? 'strong' : 'em'
}
// `*(*a*)*` builds one `em`: a node holds at most one mark of each type. Atlassian's schema gives a hard break no marks.
// `*(*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) || node.type === 'hardBreak') return node
if (!isNode(node) || takesNoMarks(node)) return node
const marks = nodeMarks(node)
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
})
+8 -2
View File
@@ -837,7 +837,11 @@ test('builds the hard break a mark spelling spans with no marks, in every reader
}
assert.deepEqual(content(losslessMarkdownToAdf('~~a\\\nb~~\n')), broken(strike))
assert.deepEqual(content(losslessMarkdownToAdf('!adf:underline[a!adf:hardBreak{}b]\n')), broken(underline))
assert.deepEqual(content(losslessMarkdownToAdf('_!adf:hardBreak{localId=x}_\n')), [{ content: [{ attrs: { localId: 'x' }, type: 'hardBreak' }], type: 'paragraph' }])
const localId = '01a0431d-201e-7400-a175-9c04d3628f95'
assert.deepEqual(content(losslessMarkdownToAdf(`_!adf:hardBreak{localId=${localId}}_\n`)), [{ content: [{ attrs: { localId }, type: 'hardBreak' }], type: 'paragraph' }])
for (const read of [commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf]) {
assert.deepEqual(content(read('[\\\n](/u)\n')), [{ content: [marked('/u', link('/u'))], type: 'paragraph' }])
}
})
test('decodes the fenced info string the block walk leaves raw', () => {
@@ -1177,9 +1181,11 @@ test('names the link a directive link wraps, no link holding another', () => {
})
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'
const named = 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps nothing a mark rides'
assert.equal(content(losslessMarkdownToAdf('!adf:underline[]\n')), named)
assert.equal(content(losslessMarkdownToAdf('!adf:underline{}\n')), named)
assert.equal(content(losslessMarkdownToAdf('!adf:underline[!adf:hardBreak{}]\n')), named)
assert.equal(content(losslessMarkdownToAdf('!adf:link[!adf:hardBreak{}]{href="/u" title=t}\n')), named.replace('underline', 'link'))
})
test('reads the text break only between two text nodes CommonMark joins, building no node', () => {
+15 -1
View File
@@ -6,7 +6,7 @@
`Bar = 9`
`Next ID = 89`
`Next ID = 91`
| Goal | W |
|---|---|
@@ -25,10 +25,12 @@
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|---|---|---|---|---|---|---|---|---|---|
| 90 | 0.2.0 | | **Describe only the formats the release converts in `package.json`'s description and the README's tagline.** | 1 | 1 | 6 | 7 | 3 | 21.7 |
| 88 | 0.2.0 | defect | **Build a `blockTaskItem` holding only the paragraphs and extensions Atlassian's schema allows, in `portableMarkdownToAdf`.** | 4 | 4 | 6 | 7 | 4 | 16.4 |
| 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists, in every reader, and retire `!adf:listBreak`.** | 4 | 5 | 5 | 8 | 4, 5 | 15.5 |
| 61 | 0.2.0 | decision | **Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.** | 6 | 8 | 3 | 8 | 1 | 14.0 |
| 86 | 0.2.0 | | **Write an image whose `media` only a link marks, and one captioned with plain text, in the CommonMark spelling a writer panel picks, in `adfToLosslessMarkdown`.** | 4 | 4 | 5 | 6 | 4, 6 | 13.0 |
| 89 | 0.2.0 | | **Build a `date`, `emoji`, `inlineCard`, `mention` or `status` holding no mark Atlassian's schema withholds from it, in every reader.** | 4 | 4 | 5 | 6 | 4 | 13.0 |
| 85 | 0.2.0 | defect | **Write an external image a link marks as `[![alt](url)](href)` in `adfToPortableMarkdown`, keeping the link target.** | 3 | 3 | 6 | 7 | 7 | 12.9 |
| 60 | 0.2.0 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 2 | 6 | 2 | 10.7 |
| 59 | 0.2.0 | decision | **Group the directive grammar into `src/markdown/directive/`, move `Read<T>` to `result.ts`, and move `Flavour` and `flavourClaims` to `markdown/flavour.ts`.** | 3 | 5 | 2 | 6 | 2 | 10.4 |
@@ -66,6 +68,11 @@
## Details
### 90. Describe only the formats the release converts in `package.json`'s description and the README's tagline.
Both say "to and from markdown and HTML", and `0.2.0` ships no HTML (item 7 brings it in `0.3.0`):
`docs/decisions.md` §Docs describe the release being built.
### 88. Build a `blockTaskItem` holding only the paragraphs and extensions Atlassian's schema allows, in `portableMarkdownToAdf`.
`- [x] done\n\n ![a](u)` builds a `blockTaskItem` holding a `mediaSingle`, and `- [x] done\n\n > q`
@@ -98,6 +105,13 @@ Every reader reads `[![alt](url)](href)` and `![alt](url "caption")` as these sh
(`docs/decisions.md` §The formats are API): changing it inside `0.2.0` spares consumers a second
breaking change to stored markdown.
### 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. `InlineNodeModel.marks` (`src/adf/inline-nodes.ts`) then names the mark
types a node takes, and the emitter carries a node holding another.
### 85. Write an external image a link marks as `[![alt](url)](href)` in `adfToPortableMarkdown`, keeping the link target.
Today `[![a](/u)](/v)` reads as the image whose `media` the link marks, and `adfToPortableMarkdown`