From b49e134aca836c24a951cf803ec00ab1823e48d2 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 22 Oct 2026 22:15:34 +0200 Subject: [PATCH] Keep an image's URL and title inside a link as (image) after the link --- CHANGELOG.md | 3 ++ README.md | 10 ++++--- corpus/normalization/images.json | 18 ++++++++++- docs/decisions.md | 23 ++++++++------ docs/reading-cost.md | 4 +++ spec/flavour.md | 6 ++-- src/markdown/parse/inline-content.ts | 35 +++++++++++++++------- src/markdown/parse/markdown-to-adf.test.ts | 26 +++++++++++----- todo.md | 15 +--------- 9 files changed, 93 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4293b9..a22f456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ schema requires: ``**`npm ci`**`` now reads as plain code, and a directive mark other than a link 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"))`. - `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 14455dc..3335cb8 100644 --- a/README.md +++ b/README.md @@ -554,10 +554,12 @@ Serves [Goals](#goals) 1, 4 and 5. blank line between it and any other line. - A code span inside emphasis reads as code without the emphasis, since ADF gives code text no 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. 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 its link's title when - it becomes linked alt text. Inside another link, the title is dropped. + - 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. + - An image's title becomes its caption when the image stays an image, and otherwise the title of + the link to the image. And these, for other reasons: a shortcut reference matching its definition only under Unicode case folding stays unresolved, and a link whose text holds an autolink keeps the inner link and leaves diff --git a/corpus/normalization/images.json b/corpus/normalization/images.json index d1799c1..b21062f 100644 --- a/corpus/normalization/images.json +++ b/corpus/normalization/images.json @@ -110,7 +110,23 @@ "type": "text" }, { - "text": ", ", + "text": " (", + "type": "text" + }, + { + "marks": [ + { + "attrs": { + "href": "https://example.com/moon.png" + }, + "type": "link" + } + ], + "text": "image", + "type": "text" + }, + { + "text": "), ", "type": "text" }, { diff --git a/docs/decisions.md b/docs/decisions.md index 79ecf08..170d7d4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -231,8 +231,8 @@ 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 of hard breaks alone reads as its destination, then the breaks 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 +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. Every reader reads every CommonMark image and empty link, as `spec/flavour.md` §The CommonMark image @@ -242,9 +242,13 @@ reads as the URL, and an empty link text as its destination. A link text holding reads as its destination, then those breaks: four of seven readers expected that 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 -drop. The panel chose this by Goal 6, but Goal 7 holds the image and its title as content, so this -stands only until `todo.md` item 122 ends it. +Inside any other link, the alt text is plain text the link marks, and ` (image)` follows the link, +`image` linked to the image's URL and titled by its title, so Goal 7 keeps both. For a link whose +text is the image alone, the bot, the LLM pipeline and a support agent picked this 3 of 3. For a +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. 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 @@ -302,14 +306,15 @@ counts as content (§An image's title is its caption when the image stands alone 2026-10-14, a reader panel of the export tool, the LLM pipeline and a support agent, and a writer panel of a product manager, an engineer and that support agent, 3 of 3 each. Goals 6 and 7. Valid -while the portable reader reads an image inside a link as its alt text, linked. +while ADF holds no inline external image, so the portable reader reads an image inside a link as +linked text. Where `adfToPortableMarkdown` writes an external image inline, as in a table cell, and a link to another target marks it, the link keeps its target and the image follows as a link reading `image`. With no alt text, the link text is the link's target, which is how the reader reads `[](href)`, so -the rewrite holds. Every seat first wrote `[![alt](url)](href)`, which reads back as `[alt](href)` -and so breaks the byte-for-byte rewrite. Each then picked this over `[alt](url)` and `[alt](href)`, -which drop a target. +the rewrite holds. Every seat first wrote `[![alt](url)](href)`, which reads back as linked text +written in another spelling, and so breaks the byte-for-byte rewrite. Each then picked this over +`[alt](url)` and `[alt](href)`, which drop a target. ## Ids stay site-local diff --git a/docs/reading-cost.md b/docs/reading-cost.md index 63dfd59..aa83b20 100644 --- a/docs/reading-cost.md +++ b/docs/reading-cost.md @@ -466,3 +466,7 @@ Read most (opens and searches): ## Reading cost, chunk 0ce006dccb26, finished 2026-10-22T19:39:09Z 130599 new input tokens before the first commit. + +## Reading cost, chunk 3d545906ebca, finished 2026-10-22T20:07:20Z + +125784 new input tokens before the first commit. diff --git a/spec/flavour.md b/spec/flavour.md index d58b243..aacd823 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -329,8 +329,10 @@ content, in a heading, a table cell or an `!adf:paragraph`, and in the paragraph marker opens. 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 title reads as the image. An empty alt text reads as the destination, as an empty link text does. Inside -a link's text, the alt text is plain text the link marks, and the image's destination and title -drop (`docs/decisions.md` §An image reads as an image only alone in its paragraph). +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). ### Tables diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 9aa687a..8f1008b 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -34,7 +34,8 @@ type OwnMarks = { kind: 'ownMarks'; node: AdfNode } type Inline = AdfNode | OwnMarks | TextBreak -type Scanned = { image?: AdfNode; nodes: Inline[] } +// `images` are the destinations of the images an `!adf:link` slot holds, for the link to follow with (image). +type Scanned = { image?: AdfNode; images: LinkDefinition[]; nodes: Inline[] } export type LinkDefinitions = ReadonlyMap @@ -51,7 +52,7 @@ type Piece = | Bracket | OwnMarks | Image - | { kind: 'nodes'; nodes: Inline[] } + | { images?: LinkDefinition[]; kind: 'nodes'; nodes: Inline[] } | TextBreak | { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number } | { closes: boolean; kind: 'highlight'; opens: boolean } @@ -78,7 +79,7 @@ type Scan = { // What a directive's content slot inherits from the content holding it. type SharedScan = Pick -type SlotContent = { nodes: Inline[] } +type SlotContent = Pick const ownMarksInMark = 'move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks' const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' } @@ -204,7 +205,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result< if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this content slot: a slot holds one text node`, scan.path) const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), scan.path) if (!node.ok) return node - return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : { kind: 'ownMarks', node: node.value }) + return success(node.value.marks === undefined ? { images: slot.value?.images ?? [], kind: 'nodes', nodes: [node.value] } : { kind: 'ownMarks', node: node.value }) } function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result { @@ -217,7 +218,8 @@ function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotC } const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, slot.nodes, index) : undefined if (refused !== undefined) return refused - return success({ kind: 'nodes', nodes: applyMark(slot.nodes, mark) }) + if (mark.type === 'link') return success({ kind: 'nodes', nodes: [...applyMark(slot.nodes, mark), ...imageLinks(slot.images)] }) + return success({ images: slot.images, kind: 'nodes', nodes: applyMark(slot.nodes, mark) }) } // 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. @@ -236,7 +238,7 @@ function slotContent(scan: Scan, span: DirectiveSpan): Result { const nodes = resolveNodes(scan.pieces.map((piece) => (scan.inLink && piece.kind === 'image' && piece.link === undefined ? piece : linkedAlt(piece))), scan, true) if (!nodes.ok) return nodes + const images = scan.inLink ? linkedImages(scan.pieces) : [] const [only] = scan.pieces - return success(scan.pieces.length === 1 && only?.kind === 'image' ? { image: mediaSingle(only), nodes: nodes.value } : { nodes: nodes.value }) + return success(scan.pieces.length === 1 && only?.kind === 'image' ? { image: mediaSingle(only), images, nodes: nodes.value } : { images, nodes: nodes.value }) } -// spec/flavour.md, The CommonMark image: an image no paragraph holds alone reads as its alt text, linked. +// spec/flavour.md, The CommonMark image: an image no paragraph holds alone reads as its alt text, linked, and inside a link as the link's text, then (image) linked to it. function linkedAlt(piece: Piece): Piece { if (piece.kind !== 'image') return piece - return { kind: 'nodes', nodes: linkTo(piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }], piece.link ?? piece.definition) } + const alt = linkTo(piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }], piece.link ?? piece.definition) + return { kind: 'nodes', nodes: piece.link === undefined ? alt : [...alt, ...imageLinks([piece.definition])] } +} + +function linkedImages(pieces: readonly Piece[]): LinkDefinition[] { + return pieces.flatMap((piece) => { + if (piece.kind === 'image') return piece.link === undefined ? [piece.definition] : [] + return piece.kind === 'nodes' ? (piece.images ?? []) : [] + }) +} + +function imageLinks(definitions: readonly LinkDefinition[]): AdfNode[] { + return definitions.flatMap((definition) => [{ text: ' (', type: 'text' }, { marks: [linkMark(definition)], text: 'image', type: 'text' }, { text: ')', type: 'text' }]) } function mediaSingle({ alt, definition, link }: Image): AdfNode { @@ -406,7 +421,7 @@ function linkPiece(scan: Scan, inner: readonly Piece[], definition: LinkDefiniti 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) }) + return success({ kind: 'nodes', nodes: [...linkTo(nodes.value, definition), ...imageLinks(linkedImages(inner))] }) } // 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 17ee554..78e117d 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -1134,7 +1134,7 @@ test('reads an image alone in a link as the image the link marks, and the link a const captioned = linked(image('/u', 'a'), link('/v')) assert.deepEqual(content(losslessMarkdownToAdf('[![a](/u "c")](/v)\n')), [{ ...captioned, content: [...(captioned.content ?? []), { content: [text('c')], type: 'caption' }] }]) assert.deepEqual(content(losslessMarkdownToAdf('[![a][i]][v]\n\n[i]: /u\n[v]: /v\n')), [linked(image('/u', 'a'), link('/v'))]) - assert.deepEqual(content(losslessMarkdownToAdf('[[![a](/u)](/v)](/w)\n')), [{ content: [text('['), marked('a', link('/v')), text('](/w)')], type: 'paragraph' }]) + assert.deepEqual(content(losslessMarkdownToAdf('[[![a](/u)](/v)](/w)\n')), [{ content: [text('['), marked('a', link('/v')), text(' ('), marked('image', link('/u')), text(')](/w)')], type: 'paragraph' }]) }) test('reads an image no paragraph holds alone as its alt text, linked to its destination', () => { @@ -1150,13 +1150,25 @@ test('reads an image no paragraph holds alone as its alt text, linked to its des assert.deepEqual(content(losslessMarkdownToAdf('a ! b\n')), [paragraph('a ! b')]) }) -test('reads an image inside a link not alone in its paragraph as plain alt text the link marks, dropping its own destination', () => { - assert.deepEqual(firstContent('See [![a](/u "t")](/v).\n'), [text('See '), marked('a', link('/v')), text('.')]) - assert.deepEqual(firstContent('See [![](/u)](/v).\n'), [text('See '), marked('/v', link('/v')), text('.')]) - assert.deepEqual(firstContent('[b ![a](/u)](/v)\n'), [marked('b a', link('/v'))]) +test('reads an image inside a link not alone in its paragraph as plain alt text the link marks, then (image) linked to its destination', () => { + const image = (destination: string, title?: string): AdfNode[] => [text(' ('), marked('image', link(destination, title)), text(')')] + assert.deepEqual(firstContent('See [![a](/u "t")](/v).\n'), [text('See '), marked('a', link('/v')), text(' ('), marked('image', link('/u', 't')), text(').')]) + assert.deepEqual(firstContent('See [![](/u)](/v).\n'), [text('See '), marked('/v', link('/v')), text(' ('), marked('image', link('/u')), text(').')]) + assert.deepEqual(firstContent('[b ![a](/u "t") c](/v)\n'), [marked('b a c', link('/v')), ...image('/u', 't')]) + assert.deepEqual(firstContent('[![a](/u) and ![b](/w)](/v)\n'), [marked('a and b', link('/v')), text(' ('), marked('image', link('/u')), text(') ('), marked('image', link('/w')), text(')')]) + assert.deepEqual(firstContent('*[b ![a](/u)](/v)*\n'), [marked('b a', em, link('/v')), marked(' (', em), marked('image', em, link('/u')), marked(')', em)]) const unspellable = { attrs: { href: 'a\nb' }, type: 'link' } - assert.deepEqual(firstContent('!adf:link[b ![a](/u)]{href="a\\nb"}\n'), [marked('b a', unspellable)]) - assert.deepEqual(firstContent('!adf:link[!adf:underline[![a](/u)]]{href="a\\nb"}\n'), [marked('a', unspellable, { type: 'underline' })]) + assert.deepEqual(firstContent('!adf:link[b ![a](/u)]{href="a\\nb"}\n'), [marked('b a', unspellable), ...image('/u')]) + assert.deepEqual(firstContent('!adf:link[!adf:underline[![a](/u)]]{href="a\\nb"}\n'), [marked('a', unspellable, { type: 'underline' }), ...image('/u')]) + assert.deepEqual(firstContent('!adf:link[![a](/u) !adf:underline[![b](/w)]]{href="a\\nb"}\n'), [ + marked('a ', unspellable), + marked('b', unspellable, { type: 'underline' }), + text(' ('), + marked('image', link('/u')), + text(') ('), + marked('image', link('/w')), + 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') }) diff --git a/todo.md b/todo.md index 867e018..b84a4f9 100644 --- a/todo.md +++ b/todo.md @@ -12,14 +12,13 @@ | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| -| 122 | 0.3.0 | | defect | **Keep an image's URL and title when a link's text holds the image, instead of dropping them.** | 3 | 1 | 2 | 4 | 5 | 7 by 4 | 10.5 | | 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.** | 4 | 1 | 3 | 6 | 6 | code health by 7 | 10.2 | | 125 | 0.3.0 | | | **Anchor each level of L, E and I in `docs/score-anchors.md` with two or three shipped items.** | 3 | 2 | 3 | 6 | 4 | code health by 2 | 8.7 | | 33 | 0.3.0 | | defect | **Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.** | 3 | 1 | 2 | 4 | 5 | 9 by 3 | 7.8 | | 109 | 0.3.0 | | defect | **Spell nested lists and quotes in time linear in the markdown.** | 3 | 1 | 2 | 4 | 5 | 9 by 3 | 7.8 | | 115 | 0.3.0 | | defect | **Build text holding no `border` mark in `losslessMarkdownToAdf`, and have `adfToLosslessMarkdown` carry text holding one.** | 2 | 1 | 2 | 3 | 3 | 4 by 3 | 4.6 | | 91 | 0.3.0 | 95 | | **Spell an emphasis once across the code spans, an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers.** | 3 | 2 | 2 | 5 | 5 | 6 by 6 | 3.6 | -| 73 | 0.3.0 | 33, 91, 95, 109, 115, 122, 125 | | **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 | 2 | 3 | 2 | 3 by 8 | 16.9 | +| 73 | 0.3.0 | 33, 91, 95, 109, 115, 125 | | **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 | 2 | 3 | 2 | 3 by 8 | 16.9 | | 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 | | 75 | 0.4.0 | | | **Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 17.4 | | 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 | @@ -55,18 +54,6 @@ ## Details -### 122. Keep an image's URL and title when a link's text holds the image, instead of dropping them. - -When a link's text holds an image, the reader turns the image into its alt text, linked, and drops -the image's URL and title (`docs/decisions.md` §An image reads as an image only alone in its -paragraph). README `## Goals` Goal 7 holds an image and a title as content, and a Maintain goal -holds whatever it costs. ADF holds no inline external image, so the item must find a shape that -keeps the image, its title and the link together. When it lands, it also rewrites three statements -that will no longer hold: the README's guarantee that inside a link an image reads as plain text -the link marks, its guarantee that inside another link the title is dropped, and the sentence in -`docs/decisions.md` §An image reads as an image only alone in its paragraph that says the drop -stands until this item ends it. - ### 95. 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. `escapeClosedRuns` works out which backtick strings an escape leaves, `unspellableRuns` rebuilds the