diff --git a/AGENTS.md b/AGENTS.md index 5700e2c..09c0e64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f82fef..eeb65f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 35d515a..2b4530a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/corpus/round-trip/combinations/link-definition-directive.json b/corpus/round-trip/combinations/link-definition-directive.json index ad3fd23..7f71def 100644 --- a/corpus/round-trip/combinations/link-definition-directive.json +++ b/corpus/round-trip/combinations/link-definition-directive.json @@ -24,7 +24,7 @@ "content": [ { "attrs": { - "timestamp": "]:a" + "id": "]:a" }, "marks": [ { @@ -34,7 +34,7 @@ "type": "link" } ], - "type": "date" + "type": "mediaInline" } ], "type": "paragraph" diff --git a/corpus/round-trip/combinations/link-definition-directive.md b/corpus/round-trip/combinations/link-definition-directive.md index 389e4ab..bfd7623 100644 --- a/corpus/round-trip/combinations/link-definition-directive.md +++ b/corpus/round-trip/combinations/link-definition-directive.md @@ -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"} diff --git a/corpus/round-trip/combinations/mark-run.json b/corpus/round-trip/combinations/mark-run.json index d949442..8c5579d 100644 --- a/corpus/round-trip/combinations/mark-run.json +++ b/corpus/round-trip/combinations/mark-run.json @@ -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" diff --git a/corpus/round-trip/combinations/mark-run.md b/corpus/round-trip/combinations/mark-run.md index 051f819..79dabab 100644 --- a/corpus/round-trip/combinations/mark-run.md +++ b/corpus/round-trip/combinations/mark-run.md @@ -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) diff --git a/corpus/round-trip/inline-nodes/mark-content-slot.md b/corpus/round-trip/inline-nodes/mark-content-slot.md deleted file mode 100644 index d4c1882..0000000 --- a/corpus/round-trip/inline-nodes/mark-content-slot.md +++ /dev/null @@ -1 +0,0 @@ -**!adf:mention[@Mikael]{id=01a032c3-7a7c-775f-a730-2d79351338b4}** owns the rebuild. diff --git a/corpus/round-trip/inline-nodes/marks.json b/corpus/round-trip/inline-nodes/marks.json index fb563d3..7b3e80b 100644 --- a/corpus/round-trip/inline-nodes/marks.json +++ b/corpus/round-trip/inline-nodes/marks.json @@ -60,11 +60,6 @@ "id": "01a032c3-7a7c-775f-a730-2d79351338b4", "text": "@Mikael" }, - "marks": [ - { - "type": "underline" - } - ], "type": "mention" }, { diff --git a/corpus/round-trip/inline-nodes/marks.md b/corpus/round-trip/inline-nodes/marks.md index 4a1c203..36e6e15 100644 --- a/corpus/round-trip/inline-nodes/marks.md +++ b/corpus/round-trip/inline-nodes/marks.md @@ -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} diff --git a/corpus/round-trip/inline-nodes/mark-content-slot.json b/corpus/round-trip/inline-nodes/withheld-mark.json similarity index 100% rename from corpus/round-trip/inline-nodes/mark-content-slot.json rename to corpus/round-trip/inline-nodes/withheld-mark.json diff --git a/corpus/round-trip/inline-nodes/withheld-mark.md b/corpus/round-trip/inline-nodes/withheld-mark.md new file mode 100644 index 0000000..8f92e09 --- /dev/null +++ b/corpus/round-trip/inline-nodes/withheld-mark.md @@ -0,0 +1 @@ +!adf:carry{json="{\"attrs\":{\"id\":\"01a032c3-7a7c-775f-a730-2d79351338b4\",\"text\":\"@Mikael\"},\"marks\":[{\"type\":\"strong\"}],\"type\":\"mention\"}"} owns the rebuild. diff --git a/docs/decisions.md b/docs/decisions.md index 196ee66..7f22016 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 `` 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 diff --git a/spec/flavour.md b/spec/flavour.md index 2cd9e63..8877170 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -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 — `` 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 diff --git a/src/adf/inline-nodes.ts b/src/adf/inline-nodes.ts index edcec5b..61da92f 100644 --- a/src/adf/inline-nodes.ts +++ b/src/adf/inline-nodes.ts @@ -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> = { - 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> = { 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 { diff --git a/src/conformance/adf-schema.test.ts b/src/conformance/adf-schema.test.ts index 9dc7d62..96fd7a3 100644 --- a/src/conformance/adf-schema.test.ts +++ b/src/conformance/adf-schema.test.ts @@ -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; marked: Set } { +function schemaTypes(): { held: Map; marked: Map> } { const held = new Map() - const marked = new Set() + const marked = new Map>() 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; marked: Set } { for (const type of (properties.get('type') ?? []).flatMap((schema) => enumStrings(schema, `${where} type`))) { const kinds = held.get(type) ?? new Map>() 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() kinds.set(attribute, attributeKinds) @@ -120,6 +120,12 @@ function schemaTypes(): { held: Map; marked: Set } { 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}`)]]))] diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 6e7060c..b1a78fc 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -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') diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 512c75d..87800c2 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -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, 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 } diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index ee9e040..eff0347 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -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 { 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] } }) diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index a673768..2fe6994 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -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', () => { diff --git a/todo.md b/todo.md index fc0f535..16fb9da 100644 --- a/todo.md +++ b/todo.md @@ -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`.** | 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`. @@ -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