From 28878122ee017208e9c3644a3bde376cbd3c7855 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 22 Oct 2026 23:44:26 +0200 Subject: [PATCH] =?UTF-8?q?Answer=20product-owner=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=201=20=F0=9F=9F=A1;=20write=20no=20(image)=20afte?= =?UTF-8?q?r=20a=20link=20to=20the=20image=20itself?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++-- README.md | 5 +++-- docs/decisions.md | 9 ++++++--- spec/flavour.md | 5 +++-- src/markdown/external-image.ts | 2 +- src/markdown/parse/inline-content.ts | 20 ++++++++++++++------ src/markdown/parse/markdown-to-adf.test.ts | 9 +++++++++ 7 files changed, 40 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a22f456..ae8c708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,10 @@ around only code spans is `unsupported-node-shape`. `adfToLosslessMarkdown` writes code text holding another mark as `!adf:carry{json="…"}`, and `adfToPortableMarkdown` drops that mark. - Every reader keeps the URL and title of an image inside a link's text, where it dropped them: - `(image)` follows the link, linked to the image, so `[look at ![the moon](moon.png "Full") tonight](/moon)` - reads as `[look at the moon tonight](/moon) ([image](moon.png "Full"))`. + `(image)` follows the link, linked to the image, so + `[look at ![the moon](moon.png "Full") tonight](/moon)` reads as + `[look at the moon tonight](/moon) ([image](moon.png "Full"))`. A link to the image's own URL + takes no `(image)`, unless the image's title is one the link lacks. - `adfToPortableMarkdown` keeps both targets of a linked external image standing inline, as in a table cell: `[alt](href) ([image](url))`, where it wrote `[alt](url)` and dropped the link's target. - `adfToPortableMarkdown` keeps a link title holding a backslash or an entity reference such as diff --git a/README.md b/README.md index 3335cb8..aca73c2 100644 --- a/README.md +++ b/README.md @@ -556,8 +556,9 @@ Serves [Goals](#goals) 1, 4 and 5. mark but a link or an annotation: ``**`npm ci`**`` reads as `` `npm ci` ``. - Inside a link, an image reads as plain text the link marks, and `(image)` follows the link, linked to the image: `[look at ![the moon](moon.png) tonight](/moon)` reads as - `[look at the moon tonight](/moon) ([image](moon.png))`. A link alone in its paragraph whose - whole text is one image reads as that image, with the link marking it. + `[look at the moon tonight](/moon) ([image](moon.png))`. A link to the image's own URL takes + no `(image)`, unless the image's title is one the link lacks. A link alone in its paragraph + whose whole text is one image reads as that image, with the link marking it. - An image's title becomes its caption when the image stays an image, and otherwise the title of the link to the image. diff --git a/docs/decisions.md b/docs/decisions.md index 170d7d4..3eaebec 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -232,8 +232,9 @@ where its reference implementation nests one `` in another. 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; an image -inside a link 2026-10-22, a reader panel, 3 of 3 and 5 of 7. 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. +inside a link 2026-10-22, a reader panel, 3 of 3 and 5 of 7. 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. 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. @@ -248,7 +249,9 @@ text is the image alone, the bot, the LLM pipeline and a support agent picked th link holding words beside the image they split on where `(image)` stands, and four more readers, the bot, the LLM pipeline, a product manager and an engineer, made it 5 of 7 for after the whole link over right after the alt text. Every reader rejected linking the alt text to the image: the words -lose the link they were written in. +lose the link they were written in. A link to the image's own URL takes no `(image)`, unless the +image holds a title the link does not: it would repeat the link, as `adfToPortableMarkdown` never +writes it (§A linked image standing inline in portable markdown). In `portableMarkdownToAdf`, a `[!WORD]` marker with no `+` or `-` stands apart from the lines after it, and so does a `+` or `-` callout's title line, so an image alone on the marker line or after the diff --git a/spec/flavour.md b/spec/flavour.md index aacd823..c768117 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -331,8 +331,9 @@ does a `+` or `-` callout's title line, so an image alone on the marker line or reads as the image. An empty alt text reads as the destination, as an empty link text does. Inside a link's text, an `!adf:link`'s included, the alt text is plain text the link marks, and after the link stands ` (image)`, `image` linked to the image's destination and titled by its title, one for -each image in the order they stand (`docs/decisions.md` §An image reads as an image only alone in -its paragraph). +each image in the order they stand. An image whose destination is the link's own takes none, unless +it holds a non-empty title the link does not (`docs/decisions.md` §An image reads as an image only +alone in its paragraph). ### Tables diff --git a/src/markdown/external-image.ts b/src/markdown/external-image.ts index d5013e9..6b3f2e1 100644 --- a/src/markdown/external-image.ts +++ b/src/markdown/external-image.ts @@ -13,7 +13,7 @@ export function externalMedia(alt: string, url: string, link?: LinkDefinition): return link === undefined ? media : { ...media, marks: [linkMark(link)] } } -// spec/flavour.md, The CommonMark image: the ` (image)` standing after a link for an image inside it, `image` built by `link`. +// The ` (image)` after a link for an image inside it, as the readers build it (spec/flavour.md, The CommonMark image) and `adfToPortableMarkdown` writes it; `link` builds `image`. export function imageAside(link: (text: string) => AdfNode[]): AdfNode[] { return [{ text: ' (', type: 'text' }, ...link('image'), { text: ')', type: 'text' }] } diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index c4fae46..e74ba7a 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -45,7 +45,7 @@ type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number } type Image = { alt: string; definition: LinkDefinition; kind: 'image'; link?: LinkDefinition } // The (image) after a link for one image its text holds: running text reads it, alt text drops it. -type ImageLink = { kind: 'imageLink'; nodes: Inline[] } +type ImageLink = { image: LinkDefinition; kind: 'imageLink'; nodes: Inline[] } type Nodes = { kind: 'nodes'; nodes: Inline[] } @@ -227,7 +227,10 @@ function directiveMarkPieces(scan: Scan, name: string, mark: AdfMark, slot: Slot } const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, nodes, index) : undefined if (refused !== undefined) return refused - return success([...slot.pieces.map((piece) => ({ ...piece, nodes: applyMark(piece.nodes, mark) })), ...slot.imageLinks]) + const attrs = nodeAttrs(mark) + const title = attrs['title'] + const imageLinks = mark.type === 'link' ? besideLink(slot.imageLinks, linkHref(attrs) ?? '', typeof title === 'string' ? title : undefined) : slot.imageLinks + return success([...slot.pieces.map((piece) => ({ ...piece, nodes: applyMark(piece.nodes, mark) })), ...imageLinks]) } // spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `assemble` asks the emitter. @@ -279,7 +282,7 @@ function resolvedPieces(pieces: readonly Piece[], nodes: readonly Inline[][]): R continue } if (run.length > 0) resolved.push({ kind: 'nodes', nodes: run }) - resolved.push({ kind: 'imageLink', nodes: held }) + resolved.push({ ...piece, nodes: held }) run = [] } if (run.length > 0) resolved.push({ kind: 'nodes', nodes: run }) @@ -295,7 +298,12 @@ function linkedAlt(piece: Piece, inLink: boolean): Piece[] { if (inLink && piece.kind === 'imageLink') return [] if (piece.kind !== 'image' || (inLink && piece.link === undefined)) return [piece] const alt: Piece = { kind: 'nodes', nodes: linkTo(piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }], piece.link ?? piece.definition) } - return piece.link === undefined ? [alt] : [alt, imageLink(piece.definition)] + return piece.link === undefined ? [alt] : [alt, ...besideLink([imageLink(piece.definition)], piece.link.destination, piece.link.title)] +} + +// As adfToPortableMarkdown writes it: a link to the image itself needs no (image), unless the image holds a title the link does not. +function besideLink(imageLinks: readonly ImageLink[], href: string, title: string | undefined): ImageLink[] { + return imageLinks.filter(({ image }) => image.destination !== href || (image.title !== undefined && image.title !== '' && image.title !== title)) } function linkedImages(pieces: readonly Piece[]): ImageLink[] { @@ -306,7 +314,7 @@ function linkedImages(pieces: readonly Piece[]): ImageLink[] { } function imageLink(definition: LinkDefinition): ImageLink { - return { kind: 'imageLink', nodes: imageAside((text) => [{ marks: [linkMark(definition)], text, type: 'text' }]) } + return { image: definition, kind: 'imageLink', nodes: imageAside((text) => [{ marks: [linkMark(definition)], text, type: 'text' }]) } } function mediaSingle({ alt, definition, link }: Image): AdfNode { @@ -453,7 +461,7 @@ function linkPieces(scan: Scan, inner: readonly Piece[], definition: LinkDefinit if (!nodes.value.some((node) => marksItem(node, 'link')) && nodes.value.some((node) => isNode(node) && node.type !== 'hardBreak')) { return failure('unsupported-node-shape', unlinkable, scan.path) } - return success([{ kind: 'nodes', nodes: linkTo(nodes.value, definition) }, ...linkedImages(inner)]) + return success([{ kind: 'nodes', nodes: linkTo(nodes.value, definition) }, ...besideLink(linkedImages(inner), definition.destination, definition.title)]) } // CommonMark: no link nests inside another, though an image's description holds one. diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 1f3d98e..2570d19 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -1183,6 +1183,15 @@ test('reads an image inside a link not alone in its paragraph as plain alt text assert.equal(content(losslessMarkdownToAdf('!adf:link[x [![a](/u)](/v)]{href="a\\nb"}\n')), 'unsupported-node-shape: no link wraps a link: the [content] this one marks already holds one') }) +test('writes no (image) after a link to the image itself, unless the image holds a title the link does not', () => { + assert.deepEqual(firstContent('See [![a](/u)](/u).\n'), [text('See '), marked('a', link('/u')), text('.')]) + assert.deepEqual(firstContent('See [![a](/u "t")](/u "t").\n'), [text('See '), marked('a', link('/u', 't')), text('.')]) + assert.deepEqual(firstContent('See [![a](/u)](/u "t").\n'), [text('See '), marked('a', link('/u', 't')), text('.')]) + assert.deepEqual(firstContent('See [![a](/u "t")](/u).\n'), [text('See '), marked('a', link('/u')), text(' ('), marked('image', link('/u', 't')), text(').')]) + assert.deepEqual(firstContent('[b ![a](/u) ![c](/w)](/u)\n'), [marked('b a c', link('/u')), text(' ('), marked('image', link('/w')), text(')')]) + assert.deepEqual(firstContent('!adf:link[b ![a](a b)]{href="a\\nb"}\n'), [marked('b a', { attrs: { href: 'a\nb' }, type: 'link' })]) +}) + test('carries the mark a spelling nested inside its own kind names once', () => { assert.deepEqual(content(losslessMarkdownToAdf('*(*a*)*\n')), [{ content: [marked('(a)', em)], type: 'paragraph' }]) assert.deepEqual(content(losslessMarkdownToAdf(`${'*'.repeat(600)}a${'*'.repeat(600)}\n`)), [{ content: [marked('a', strong)], type: 'paragraph' }])