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

Keep an image's URL and title inside a link as (image) after the link

This commit is contained in:
2026-10-22 22:15:34 +02:00
parent add0ac96ff
commit b49e134aca
9 changed files with 93 additions and 47 deletions
+25 -10
View File
@@ -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<string, LinkDefinition>
@@ -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<Scan, 'definitions' | 'path'>
type SlotContent = { nodes: Inline[] }
type SlotContent = Pick<Scanned, 'images' | 'nodes'>
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<Piece> {
@@ -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<SlotContent | unde
const { claims, definitions, path } = scan
const parsed = scanInline(freshScan(span.content, { definitions, path }, { claims, container: undefined, inLink: scan.inLink || span.name === 'link', spans: span.spans }))
if (!parsed.ok) return parsed
return success({ nodes: parsed.value.nodes })
return success({ images: parsed.value.images, nodes: parsed.value.nodes })
}
function flush(scan: Scan, strip: boolean): void {
@@ -252,14 +254,27 @@ function pushNode(scan: Scan, node: AdfNode): void {
function assemble(scan: Scan): Result<Scanned> {
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.
+19 -7
View File
@@ -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')
})