From 580bc862f249f062db13fa8d8733f307ffbc0961 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 11 Oct 2026 12:19:15 +0200 Subject: [PATCH] Write an external image a link marks as [![alt](url)](href) in both writers --- CHANGELOG.md | 3 +++ MIGRATION.md | 1 + README.md | 2 +- docs/decisions.md | 4 ++++ spec/flavour.md | 9 ++++---- src/markdown/emit/adf-to-markdown.test.ts | 22 +++++++++++++++++++ src/markdown/emit/image.ts | 19 ++++++++++++---- src/markdown/emit/inline-line.ts | 12 +++++++--- src/markdown/external-image.ts | 7 ++++-- src/markdown/mark-spellings.ts | 5 +++++ src/markdown/parse/inline-content.ts | 9 ++------ .../portable/adf-to-portable-markdown.test.ts | 7 ++++++ .../portable/adf-to-portable-markdown.ts | 9 +++++--- src/markdown/portable/inline-reduction.ts | 2 +- todo.md | 14 ------------ 15 files changed, 86 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bcd077..92ced11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,9 @@ - **Breaking:** a link reference definition between two list items, after a blank line and at the list's own indentation, ends the list, as CommonMark reads it; `0.1.0` read one list. See `MIGRATION.md`. +- **Breaking:** `adfToLosslessMarkdown` writes an external image only a link marks as + `[![alt](url)](href)`, and `losslessMarkdownToAdf` refuses its directive form as + `unsupported-node-shape`. Convert stored markdown per `MIGRATION.md`. - Fix the time `adfToLosslessMarkdown` and `adfToPortableMarkdown` take on text holding a long run of `*`, `_`, `~` or backticks, or a heading ending in a long run of `#`: the time is linear in the run, and a run of 20000 `*` takes milliseconds where it took seconds. diff --git a/MIGRATION.md b/MIGRATION.md index 0914d4c..4239df8 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -60,6 +60,7 @@ function migrateMarkdown(stored: string) { | --- | --- | | `:::panel info` … `:::`, the fence longer per nesting level | `!adf:panel info` … `!adf:/panel` at any depth | | `::media {id=a type=file}` | `!adf:media {id=a type=file}` | +| a `mediaSingle` directive holding one external `media` a link marks | `[![alt](url)](href)` | | `::taskItem TODO {localId=i}`: an empty `caption`, `decisionItem`, `paragraph` or `taskItem`, or an empty `heading` carrying `localId` | `!adf:taskItem TODO {localId=i}` then `!adf:/taskItem` | | `:mention[@Mikael]{id=5b10a2}` | `!adf:mention[@Mikael]{id=5b10a2}` | | the `adf` code fence and `:adf{json="…"}` | the `adf:` code fence, its JSON without `type`, and `!adf:carry{json="…"}` | diff --git a/README.md b/README.md index dafa6fa..8d83026 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ saving what this pair read replaces mentions, attachments and macros with text. | `decisionList` | a bullet list | — | | `mention`, `status`, `emoji`, `date` | their text: `@` kept, a mention with none `@` and its id, an emoji its `shortName` without, a date `2026-09-13` in UTC | — | | `inlineCard`, `blockCard`, `embedCard` | a link to the card's URL, else its name | — | -| external `media` | `![alt](url)` in a block, `[alt](url)` inline | — | +| external `media` | `![alt](url)` in a block, `[![alt](url)](href)` where a link marks it, `[alt](url)` inline | — | | stored `media`, `mediaInline`, `extension`, `inlineExtension` | their `alt` or `text` | — | | `layoutSection`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`, `extensionFrame`, `caption`, a node this version does not know | its blocks or its text | — | | `placeholder` | nothing | — | diff --git a/docs/decisions.md b/docs/decisions.md index aa870f9..6eb09fe 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -241,6 +241,10 @@ The same panel confirmed the other readings on 2026-10-09: a title as the captio text as linked alt text, and an empty link text as its URL, each 3 of 3; `[]()` as nothing, 5 of 7. Their CommonMark suite exceptions are `node-model` (§The CommonMark suite checks three ways). +Writing, a writer panel of a product manager, an engineer and a support agent on 2026-10-11, 3 of +3: `adfToLosslessMarkdown` writes an image only a link marks as `[![alt](url)](href)`, and a +captioned image as the `!adf:caption` directive, since a title reads as hover text, not a caption. + ## Ids stay site-local 2026-08-23, the maintainer. Goal 1. Valid while ADF ids are minted per site. diff --git a/spec/flavour.md b/spec/flavour.md index b1133f5..15dfaa3 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -311,10 +311,11 @@ it contributing its own text, a node spelling its text in the content slot contr and a break of either kind a space. A non-empty title, `![alt](url "title")`, adds a `caption` holding the title as one text node, and a link whose entire text is the image, `[![alt](url)](href)`, marks the `media`. `adfToLosslessMarkdown` emits the image form only for the -bare shape — those attrs and no others, no marks on either node, no caption, and a -`media` carrying nothing beyond `alt`, `type` and `url` — and only where CommonMark spells the pair: -a destination or a description the image form cannot hold, an empty `alt` included, takes the -directive form instead. +bare shape — those attrs and no others, no marks on the `mediaSingle`, no caption, and a +`media` carrying nothing beyond `alt`, `type` and `url` and no mark but a link holding nothing +beyond `href` and `title`, spelled `[![alt](url)](href "title")` — and only where CommonMark spells +it: a destination, a title or a description the image form cannot hold, an empty `alt` included, +takes the directive form instead. `mediaInline` carries a media `collection` + `id`, never a URL, so anywhere else an image reads as its alt text linked to its destination, the link titled by the image's title: beside other diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 49a413a..1eb4465 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -626,6 +626,28 @@ test('spells the image form for exactly the centered external media shape', () = assert.equal(code(adfToLosslessMarkdown(document({ attrs: { layout: 'center' }, content: [{ ...media, text: 'x' }], type: 'mediaSingle' }))), 'unsupported-node-shape') }) +test('spells a media only a link marks as the image the link wraps', () => { + const url = 'https://example.com/moon.png' + const linked = (alt: string | undefined, ...marks: AdfMark[]): AdfDocument => + document({ attrs: { layout: 'center' }, content: [{ attrs: alt === undefined ? { type: 'external', url } : { alt, type: 'external', url }, marks, type: 'media' }], type: 'mediaSingle' }) + const link = (attrs: AdfAttributes): AdfMark => ({ attrs, type: 'link' }) + const spelled = (written: AdfDocument, expected: string): void => { + assert.equal(markdown(adfToLosslessMarkdown(written)), expected) + assert.deepEqual(losslessMarkdownToAdf(expected), { ok: true, value: written }) + } + spelled(linked('The moon', link({ href: 'https://example.com/docs' })), `[![The moon](${url})](https://example.com/docs)\n`) + spelled(linked(undefined, link({ href: 'https://example.com/docs', title: 'Docs "v2"' })), `[![](${url})](https://example.com/docs "Docs \\"v2\\"")\n`) + spelled(linked('a [b] c', link({ href: '/a b' })), `[![a \\[b\\] c](${url})]()\n`) + spelled(linked('m', link({ href: '' })), `[![m](${url})]()\n`) + const fallback = (document: AdfDocument): boolean => markdown(adfToLosslessMarkdown(document)).startsWith('!adf:mediaSingle {layout=center}') + assert.ok(fallback(linked('m', link({ href: '/d', id: 'x' })))) + assert.ok(fallback(linked('m', link({ href: '/d' }), { attrs: { color: '#000', size: 1 }, type: 'border' }))) + assert.ok(fallback(linked('m', link({ href: 'a\\b' })))) + assert.ok(fallback(linked('m', link({ title: 't' })))) + const captioned: AdfNode = { attrs: { layout: 'center' }, content: [{ attrs: { alt: 'm', type: 'external', url }, type: 'media' }, { content: [{ text: 'c', type: 'text' }], type: 'caption' }], type: 'mediaSingle' } + assert.ok(fallback(document(captioned))) +}) + test('spells a mediaSingle the image form does not fit as a directive', () => { const media: AdfNode = { attrs: { type: 'external', url: 'https://example.com/moon.png' }, type: 'media' } const single: AdfNode = { attrs: { layout: 'center' }, content: [media], marks: [{ type: 'border' }], type: 'mediaSingle' } diff --git a/src/markdown/emit/image.ts b/src/markdown/emit/image.ts index c0b577c..ec8ed96 100644 --- a/src/markdown/emit/image.ts +++ b/src/markdown/emit/image.ts @@ -1,13 +1,24 @@ import type { AdfNode } from '../../adf/document.ts' +import type { LinkDefinition } from '../commonmark/link-syntax.ts' import { centeredImage, externalMedia } from '../external-image.ts' -import { nodeAttrs, nodeContent } from '../../adf/document.ts' +import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { serializeCanonicalJson } from '../../canonical-json.ts' import { tryImageLine } from './inline-line.ts' export function tryImage(node: AdfNode): string | undefined { const [media] = nodeContent(node) - const { alt, url } = media === undefined ? {} : nodeAttrs(media) + if (media === undefined) return undefined + const { alt, url } = nodeAttrs(media) if (typeof url !== 'string' || (alt !== undefined && typeof alt !== 'string')) return undefined - const read = centeredImage([externalMedia(alt ?? '', url)]) - return serializeCanonicalJson(node, 'compact') === serializeCanonicalJson(read, 'compact') ? tryImageLine(alt, url) : undefined + const [mark] = nodeMarks(media) + const link = mark === undefined ? undefined : linkDefinition(nodeAttrs(mark)) + if (mark !== undefined && link === undefined) return undefined + const read = centeredImage([externalMedia(alt ?? '', url, link)]) + return serializeCanonicalJson(node, 'compact') === serializeCanonicalJson(read, 'compact') ? tryImageLine(alt, url, link) : undefined +} + +function linkDefinition({ href, title }: Readonly>): LinkDefinition | undefined { + if (typeof href !== 'string') return undefined + if (title === undefined) return { destination: href } + return typeof title === 'string' ? { destination: href, title } : undefined } diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index b9d810e..f064f30 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -1,11 +1,12 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { InlineNodeModel } from '../../adf/inline-nodes.ts' import type { LineContainer } from '../line-container.ts' +import type { LinkDefinition } from '../commonmark/link-syntax.ts' import type { WrittenFlavour } from '../portable/conventions.ts' import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts' import { carriedInline } from '../opaque-carry.ts' import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts' -import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts' +import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { flavourClaims, highlightDelimiter } from '../portable/conventions.ts' import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts' @@ -67,12 +68,17 @@ export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, f return emitted.value.line } -export function tryImageLine(alt: string | undefined, href: string): string | undefined { +// `link` wraps the image as the link's whole text: `[![alt](url)](href)`. +export function tryImageLine(alt: string | undefined, href: string, link?: LinkDefinition): string | undefined { if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined const destination = spellDestination(href) if (destination === undefined) return undefined const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }] - return attemptLine([syntax('!['), ...description, syntax(`](${destination})`)], 'paragraph', 'lossless').line + if (link === undefined) return attemptLine([syntax('!['), ...description, syntax(`](${destination})`)], 'paragraph', 'lossless').line + const target = spellLinkTarget(link.destination, link.title) + if (target === undefined) return undefined + const image = [syntax('[!['), ...description, syntax(']('), { escaping: 'bracketed-link-target', text: escapeUnbalanced(destination, '[', ']') } satisfies InlineSegment, syntax(')')] + return attemptLine([...image, syntax(`](${target})`)], 'paragraph', 'lossless').line } function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result { diff --git a/src/markdown/external-image.ts b/src/markdown/external-image.ts index 38a122b..8b33b87 100644 --- a/src/markdown/external-image.ts +++ b/src/markdown/external-image.ts @@ -1,4 +1,6 @@ import type { AdfNode } from '../adf/document.ts' +import type { LinkDefinition } from './commonmark/link-syntax.ts' +import { linkMark } from './mark-spellings.ts' // spec/flavour.md, The CommonMark image: `![alt](url)` reads to `centeredImage([externalMedia(alt, url)])`, the only shape `adfToLosslessMarkdown` writes as `![alt](url)`. export function centeredImage(content: AdfNode[]): AdfNode { @@ -6,6 +8,7 @@ export function centeredImage(content: AdfNode[]): AdfNode { } // `![](url)` builds no `alt`, so a media holding an empty one takes the directive form. -export function externalMedia(alt: string, url: string): AdfNode { - return { attrs: alt === '' ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' } +export function externalMedia(alt: string, url: string, link?: LinkDefinition): AdfNode { + const media: AdfNode = { attrs: alt === '' ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' } + return link === undefined ? media : { ...media, marks: [linkMark(link)] } } diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts index d9a814c..0f7f7ad 100644 --- a/src/markdown/mark-spellings.ts +++ b/src/markdown/mark-spellings.ts @@ -1,6 +1,7 @@ import type { AdfAttributes, AdfMark, AdfNode } from '../adf/document.ts' import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { MarkType } from '../adf/mark-attributes.ts' +import type { LinkDefinition } from './commonmark/link-syntax.ts' import { escapeUnbalanced, spellLinkTarget } from './commonmark/link-syntax.ts' import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './directive-syntax.ts' import { holdsEntityReference } from './commonmark/entity-references.ts' @@ -36,6 +37,10 @@ export function markSpelling(type: string): MarkSpelling | undefined { return { attributes, kind: spelling.kind } } +export function linkMark({ destination, title }: LinkDefinition): AdfMark { + return { attrs: title === undefined ? { href: destination } : { href: destination, title }, type: 'link' } +} + export function linkHref(attrs: AdfAttributes): string | undefined { const href = attrs['href'] return typeof href === 'string' ? href : undefined diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 1721a83..ee9e040 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -6,7 +6,7 @@ import type { InlineToken } from '../inline-tokens.ts' import type { LineContainer } from '../line-container.ts' import type { LinkDefinition } from '../commonmark/link-syntax.ts' import { centeredImage, externalMedia } from '../external-image.ts' -import { commonMarkLink, linkHref } from '../mark-spellings.ts' +import { commonMarkLink, linkHref, linkMark } from '../mark-spellings.ts' import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { highlightDelimiter } from '../portable/conventions.ts' @@ -261,14 +261,9 @@ function linkedAlt(piece: Piece): Piece { } function mediaSingle({ alt, definition, link }: Image): AdfNode { - const media = externalMedia(alt, definition.destination) const { title } = definition const caption: AdfNode[] = title === undefined || title === '' ? [] : [{ content: [{ text: title, type: 'text' }], type: 'caption' }] - return centeredImage([link === undefined ? media : { ...media, marks: [linkMark(link)] }, ...caption]) -} - -function linkMark({ destination, title }: LinkDefinition): AdfMark { - return { attrs: title === undefined ? { href: destination } : { href: destination, title }, type: 'link' } + 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. diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index c43025d..de56314 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -220,6 +220,13 @@ test('spells a card as a link to its url, or to its data url named by its data n assert.equal(portable(node('blockCard', { data: {} })), '_(link card not included)_\n') }) +test('keeps the target of a link marking an external image', () => { + const linked = (href: string, ...marks: AdfMark[]): AdfNode => ({ attrs: { alt: 'Moon', type: 'external', url: 'https://example.com/moon.png' }, marks: [{ attrs: { href, title: 'Docs', id: 'x' }, type: 'link' }, ...marks], type: 'media' }) + assert.equal(portable(node('mediaSingle', { layout: 'wide' }, linked('https://example.com/docs'))), '[![Moon](https://example.com/moon.png)](https://example.com/docs "Docs")\n') + assert.equal(portable(node('mediaSingle', {}, linked('a\\b', { attrs: { color: '#000', size: 1 }, type: 'border' }), node('caption', {}, text('The moon.')))), '[![Moon](https://example.com/moon.png)](a%5Cb "Docs")\n\nThe moon.\n') + assert.equal(portable(linked('')), '[![Moon](https://example.com/moon.png)](<> "Docs")\n') +}) + test('keeps an external image wherever it stands and spells a stored file as its alt text, else a note', () => { const media = (attrs: AdfAttributes): AdfNode => ({ attrs, type: 'media' }) const caption: AdfNode = node('caption', {}, text('The moon.')) diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index 15bbf78..0fcd3e2 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -1,11 +1,11 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' -import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts' +import { adfDocumentFault, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts' import { centeredImage, externalMedia } from '../external-image.ts' import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { flavourClaims, taskMarker } from './conventions.ts' -import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './inline-reduction.ts' +import { inlineLeaves, isBlockNodeType, oneLine, portableLink, reduceInline, writableHref } from './inline-reduction.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' @@ -350,5 +350,8 @@ function reduceMedia(media: AdfNode, reduction: Reduction): Result { if (attrs['type'] !== 'external' || typeof url !== 'string') return paragraphOfNode(media, reduction) const held = attrs['alt'] const alt = typeof held === 'string' ? oneLine(held).trim() : '' - return success([centeredImage([externalMedia(alt, writableHref(url))])]) + const image = externalMedia(alt, writableHref(url)) + const mark = nodeMarks(media).find((held) => held.type === 'link') + const link = mark === undefined ? undefined : portableLink(mark, 'paragraph') + return success([centeredImage([link === undefined ? image : { ...image, marks: [link] }])]) } diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts index e48971a..d0b1b3a 100644 --- a/src/markdown/portable/inline-reduction.ts +++ b/src/markdown/portable/inline-reduction.ts @@ -146,7 +146,7 @@ function portableMarks(marks: readonly AdfMark[], container: LineContainer, text return kept.filter((mark) => !code || mark.type !== highlight).sort((first, second) => rank(first) - rank(second)) } -function portableLink(mark: AdfMark, container: LineContainer): AdfMark | undefined { +export function portableLink(mark: AdfMark, container: LineContainer): AdfMark | undefined { const attrs = nodeAttrs(mark) const held = attrs['href'] const title = typeof attrs['title'] === 'string' ? attrs['title'].replace(/[\r\0]/g, '').replace(/\n/g, ' ') : undefined diff --git a/todo.md b/todo.md index edcb916..e461a8a 100644 --- a/todo.md +++ b/todo.md @@ -25,8 +25,6 @@ | ID | Release | Exempt | Item | R | S | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---| -| 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 | -| 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 | | 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 | 5 | 6 | 4 | 13.0 | | 60 | 0.2.1 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 2 | 6 | 2 | 10.7 | | 59 | 0.2.1 | decision | **Group the directive grammar into `src/markdown/directive/`, move `Read` to `result.ts`, and move `Flavour` and `flavourClaims` to `markdown/flavour.ts`.** | 3 | 5 | 2 | 6 | 2 | 10.4 | @@ -69,18 +67,6 @@ ## Details -### 86. 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`. - -Every reader reads `[![alt](url)](href)` and `![alt](url "caption")` as these shapes, and -`adfToLosslessMarkdown` writes the `!adf:mediaSingle` directive for both. The emitted form is API -(`docs/decisions.md` §The formats are API): changing it inside `0.2.0` spares consumers a second -breaking change to stored markdown. - -### 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` -writes `![a](/u)`, dropping `/v`: the README promises it keeps link targets (Goal 7). - ### 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`.