3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Answer product-owner round 1: 0 🔴, 1 🟡; write no (image) after a link to the image itself

This commit is contained in:
2026-10-22 23:44:26 +02:00
committed by Lillem4n
parent 49ec55bef2
commit 28878122ee
7 changed files with 40 additions and 16 deletions
+4 -2
View File
@@ -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
+3 -2
View File
@@ -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.
+6 -3
View File
@@ -232,8 +232,9 @@ where its reference implementation nests one `<a>` 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
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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' }]
}
+14 -6
View File
@@ -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.
@@ -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&#10;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' }])