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:
@@ -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 `[](href)`, a captioned image as its directive
|
||||
- Ids stay site-local
|
||||
- Portable task ids come from position
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
- `losslessMarkdownToAdf` names the CommonMark spelling to write when it refuses a directive whose
|
||||
node CommonMark spells, such as `[](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
|
||||
|
||||
|
||||
@@ -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 .` 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"}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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] }
|
||||
})
|
||||
|
||||
@@ -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​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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user