mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-13 04:08:29 +01:00
Answer architecture round 1: 1 🔴, 2 🟡; keep the (image) trailer out of alt text, refuse a node slot holding an image inside a link
This commit is contained in:
@@ -12,3 +12,8 @@ export function externalMedia(alt: string, url: string, link?: LinkDefinition):
|
||||
const media: AdfNode = { attrs: alt === '' ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' }
|
||||
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`.
|
||||
export function imageAside(link: (text: string) => AdfNode[]): AdfNode[] {
|
||||
return [{ text: ' (', type: 'text' }, ...link('image'), { text: ')', type: 'text' }]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts'
|
||||
import type { InlineSource, 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 { centeredImage, externalMedia, imageAside } from '../external-image.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'
|
||||
@@ -34,7 +34,7 @@ type OwnMarks = { kind: 'ownMarks'; node: AdfNode }
|
||||
|
||||
type Inline = AdfNode | OwnMarks | TextBreak
|
||||
|
||||
// `images` are the destinations of the images an `!adf:link` slot holds, for the link to follow with (image).
|
||||
// `images` are the images an `!adf:link` slot holds, whose (image) the link stands after itself.
|
||||
type Scanned = { image?: AdfNode; images: LinkDefinition[]; nodes: Inline[] }
|
||||
|
||||
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
|
||||
@@ -44,6 +44,9 @@ type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
|
||||
// `link` is the link whose text is this image alone.
|
||||
type Image = { alt: string; definition: LinkDefinition; kind: 'image'; link?: LinkDefinition }
|
||||
|
||||
// The (image) after a link, one per image its text holds: running text reads it, alt text drops it.
|
||||
type ImageLinks = { definitions: LinkDefinition[]; kind: 'imageLinks' }
|
||||
|
||||
type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; opens: boolean; position: number; stretch: number }
|
||||
|
||||
type Pairing = EmphasisPairing<Run>
|
||||
@@ -52,7 +55,8 @@ type Piece =
|
||||
| Bracket
|
||||
| OwnMarks
|
||||
| Image
|
||||
| { images?: LinkDefinition[]; kind: 'nodes'; nodes: Inline[] }
|
||||
| ImageLinks
|
||||
| { kind: 'nodes'; nodes: Inline[] }
|
||||
| TextBreak
|
||||
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
|
||||
| { closes: boolean; kind: 'highlight'; opens: boolean }
|
||||
@@ -66,7 +70,7 @@ type Scan = {
|
||||
// Pieces below this have been walked for openers to deactivate: an image close folds the link-marked piece into alt text, leaving this the only record that the brackets around it are doomed.
|
||||
deactivatedBefore: number
|
||||
definitions: LinkDefinitions
|
||||
// Whether an `!adf:link` slot holds this content, where an image reads as its plain alt text.
|
||||
// Whether an `!adf:link` slot holds this content through mark slots alone, where an image reads as its plain alt text and the link stands its (image) after itself.
|
||||
inLink: boolean
|
||||
openingSpellableLink: boolean
|
||||
path: ConvertErrorPath
|
||||
@@ -180,35 +184,35 @@ function readDirective(scan: Scan, inline: InlineSource, prefix: ClaimedPrefix):
|
||||
}
|
||||
|
||||
function pushDirective(scan: Scan, span: DirectiveSpan, index: number): Result<number> {
|
||||
const piece = directivePiece(scan, span, index)
|
||||
if (!piece.ok) return piece
|
||||
const pieces = directivePieces(scan, span, index)
|
||||
if (!pieces.ok) return pieces
|
||||
flush(scan, false)
|
||||
scan.pieces.push(piece.value)
|
||||
for (const piece of pieces.value) scan.pieces.push(piece)
|
||||
return success(index + span.length)
|
||||
}
|
||||
|
||||
function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<Piece> {
|
||||
function directivePieces(scan: Scan, span: DirectiveSpan, index: number): Result<Piece[]> {
|
||||
const carried = readCarriedInline(span)
|
||||
if (carried !== undefined) {
|
||||
if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
|
||||
return success({ kind: 'ownMarks', node: carried.value })
|
||||
return success([{ kind: 'ownMarks', node: carried.value }])
|
||||
}
|
||||
if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success(textBreak) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.path)
|
||||
if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success([textBreak]) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.path)
|
||||
const text = readTextDirective(span)
|
||||
if (text?.fault !== undefined) return faulted(text.fault, scan.path)
|
||||
if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] })
|
||||
const slot = slotContent(scan, span)
|
||||
if (!slot.ok) return slot
|
||||
if (text !== undefined) return success([{ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] }])
|
||||
const mark = readDirectiveMark(span.name, span.attributes, scan.path)
|
||||
if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark
|
||||
const slot = slotContent(scan, span, mark !== undefined && (scan.inLink || span.name === 'link'))
|
||||
if (!slot.ok) return slot
|
||||
if (mark !== undefined) return mark.ok ? directiveMarkPieces(scan, span.name, mark.value, slot.value, index) : mark
|
||||
const content = slot.value?.nodes
|
||||
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 ? { images: slot.value?.images ?? [], kind: 'nodes', nodes: [node.value] } : { kind: 'ownMarks', node: node.value })
|
||||
return success([node.value.marks === undefined ? { 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> {
|
||||
function directiveMarkPieces(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result<Piece[]> {
|
||||
if (slot === undefined || slot.nodes.length === 0) {
|
||||
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path)
|
||||
}
|
||||
@@ -218,12 +222,11 @@ 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
|
||||
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) })
|
||||
return success(withImageLinks({ kind: 'nodes', nodes: applyMark(slot.nodes, mark) }, slot.images))
|
||||
}
|
||||
|
||||
// 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.
|
||||
function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[], index: number): Result<Piece> | undefined {
|
||||
function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[], index: number): Result<Piece[]> | undefined {
|
||||
const href = linkHref(nodeAttrs(mark))
|
||||
if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.path)
|
||||
if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.path)
|
||||
@@ -233,10 +236,10 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[]
|
||||
return undefined
|
||||
}
|
||||
|
||||
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
|
||||
function slotContent(scan: Scan, span: DirectiveSpan, inLink: boolean): Result<SlotContent | undefined> {
|
||||
if (span.content === undefined) return success(undefined)
|
||||
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 }))
|
||||
const parsed = scanInline(freshScan(span.content, { definitions, path }, { claims, container: undefined, inLink, spans: span.spans }))
|
||||
if (!parsed.ok) return parsed
|
||||
return success({ images: parsed.value.images, nodes: parsed.value.nodes })
|
||||
}
|
||||
@@ -252,7 +255,7 @@ 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)
|
||||
const nodes = resolveNodes(scan.pieces.flatMap((piece) => linkedAlt(piece, scan.inLink)), scan, true)
|
||||
if (!nodes.ok) return nodes
|
||||
const images = scan.inLink ? linkedImages(scan.pieces) : []
|
||||
const [only] = scan.pieces
|
||||
@@ -260,21 +263,22 @@ function assemble(scan: Scan): Result<Scanned> {
|
||||
}
|
||||
|
||||
// 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
|
||||
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 linkedAlt(piece: Piece, inLink: boolean): Piece[] {
|
||||
if (inLink && piece.kind === 'imageLinks') 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] : withImageLinks(alt, [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 ?? []) : []
|
||||
return piece.kind === 'imageLinks' ? piece.definitions : []
|
||||
})
|
||||
}
|
||||
|
||||
function imageLinks(definitions: readonly LinkDefinition[]): AdfNode[] {
|
||||
return definitions.flatMap((definition) => [{ text: ' (', type: 'text' }, { marks: [linkMark(definition)], text: 'image', type: 'text' }, { text: ')', type: 'text' }])
|
||||
function withImageLinks(link: Piece, definitions: LinkDefinition[]): Piece[] {
|
||||
return definitions.length === 0 ? [link] : [link, { definitions, kind: 'imageLinks' }]
|
||||
}
|
||||
|
||||
function mediaSingle({ alt, definition, link }: Image): AdfNode {
|
||||
@@ -404,24 +408,24 @@ function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition:
|
||||
return success(false)
|
||||
}
|
||||
if (inner.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
|
||||
const piece = linkPiece(scan, inner, definition)
|
||||
if (!piece.ok) return piece
|
||||
const pieces = linkPieces(scan, inner, definition)
|
||||
if (!pieces.ok) return pieces
|
||||
truncatePieces(scan, at)
|
||||
deactivateOpeners(scan, at)
|
||||
scan.pieces.push(piece.value)
|
||||
for (const piece of pieces.value) scan.pieces.push(piece)
|
||||
return success(true)
|
||||
}
|
||||
|
||||
// An image alone in the link text keeps its piece, so a paragraph holding nothing else builds the linked image.
|
||||
function linkPiece(scan: Scan, inner: readonly Piece[], definition: LinkDefinition): Result<Piece> {
|
||||
function linkPieces(scan: Scan, inner: readonly Piece[], definition: LinkDefinition): Result<Piece[]> {
|
||||
const [only] = inner
|
||||
if (inner.length === 1 && only?.kind === 'image') return success({ ...only, link: definition })
|
||||
if (inner.length === 1 && only?.kind === 'image') return success([{ ...only, link: definition }])
|
||||
const nodes = resolveNodes(inner, scan, true)
|
||||
if (!nodes.ok) return nodes
|
||||
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), ...imageLinks(linkedImages(inner))] })
|
||||
return success(withImageLinks({ kind: 'nodes', nodes: linkTo(nodes.value, definition) }, linkedImages(inner)))
|
||||
}
|
||||
|
||||
// CommonMark: no link nests inside another, though an image's description holds one.
|
||||
@@ -447,7 +451,7 @@ function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition:
|
||||
}
|
||||
|
||||
function imageAlt(inner: readonly Piece[], scan: Scan): Result<string> {
|
||||
const nodes = resolveNodes(inner, scan, false)
|
||||
const nodes = resolveNodes(inner.filter((piece) => piece.kind !== 'imageLinks'), scan, false)
|
||||
if (!nodes.ok) return nodes
|
||||
if (nodes.value.some(isTextBreak)) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this image description: the description reads as plain alt text`, scan.path)
|
||||
return success(adfNodes(nodes.value).map(altText).join(''))
|
||||
@@ -499,6 +503,8 @@ function pieceNodes(piece: Piece): Inline[] {
|
||||
return [{ text: '', type: 'text' }]
|
||||
case 'image':
|
||||
return piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }]
|
||||
case 'imageLinks':
|
||||
return piece.definitions.flatMap((definition) => imageAside((text) => [{ marks: [linkMark(definition)], text, type: 'text' }]))
|
||||
case 'nodes':
|
||||
return piece.nodes
|
||||
case 'open':
|
||||
|
||||
@@ -1107,6 +1107,8 @@ test('reads a lone image as the media the flavour spells for it', () => {
|
||||
test('flattens the description of a lone image to the plain text alt holds', () => {
|
||||
assert.deepEqual(content(losslessMarkdownToAdf(' c](/v)\n')), [image('/v', 'a b c')])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf(' d](/e)\n')), [image('/e', 'a b d')])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('](/h)](/v)\n')), [image('/v', 'x y a')])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf(']{href="a\\nb"}](/v)\n')), [image('/v', 'x y a')])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('\n')), [image('/u', 'a b')])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('\n')), [image('/u', 'a b')])
|
||||
assert.deepEqual(content(losslessMarkdownToAdf('\n')), [image('/u', 'a b')])
|
||||
@@ -1198,6 +1200,7 @@ test('names the content slot no lone plain text node reads back from', () => {
|
||||
assert.equal(code(losslessMarkdownToAdf('!adf:status[a`b`]{color=yellow}\n')), 'unsupported-node-shape')
|
||||
assert.equal(code(losslessMarkdownToAdf('!adf:status[!adf:date{timestamp=1}]{color=yellow}\n')), 'unsupported-node-shape')
|
||||
assert.equal(content(losslessMarkdownToAdf('!adf:status[]{color=yellow}\n')), named)
|
||||
assert.equal(content(losslessMarkdownToAdf('!adf:link[x !adf:status[]{color=yellow}]{href="a\\nb"}\n')), named)
|
||||
assert.equal(code(losslessMarkdownToAdf('!adf:status[<div>]{color=yellow}\n')), 'unmappable-html')
|
||||
assert.equal(code(losslessMarkdownToAdf('!adf:date[<div>]{timestamp=1}\n')), 'unmappable-html')
|
||||
assert.equal(code(losslessMarkdownToAdf('!adf:widget[<div>]\n')), 'unmappable-html')
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { MarkRun } from '../emit/line-escaping.ts'
|
||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import { codeTextMarkTypes } from '../../adf/inline-nodes.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { imageAside } from '../external-image.ts'
|
||||
import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
@@ -80,7 +81,7 @@ function idMention(id: unknown): string | undefined {
|
||||
return typeof id === 'string' && id !== '' ? `@${id}` : undefined
|
||||
}
|
||||
|
||||
// An image standing inline is a link to it: CommonMark's inline image reads back as no node, and one inside a link as its alt text, linked.
|
||||
// An image standing inline is a link to it: the reader builds no node for an inline image, reading it as its alt text linked, and inside a link as the link's text, then (image).
|
||||
function mediaLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||
const alt = nonEmpty(attrs['alt'])
|
||||
const url = nonEmpty(attrs['url'])
|
||||
@@ -89,9 +90,7 @@ function mediaLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[],
|
||||
const href = link === undefined ? undefined : nodeAttrs(link)['href']
|
||||
if (typeof href !== 'string') return linkedLeaves(alt ?? url, url, marks, container)
|
||||
if (href === url) return textLeaves(alt ?? url, marks, container)
|
||||
const linkedAlt = textLeaves(alt ?? nonEmpty(href) ?? url, marks, container)
|
||||
const image = linkedLeaves('image', url, marks, container)
|
||||
return [...linkedAlt, textLeaf(' (', []), ...image, textLeaf(')', [])]
|
||||
return [...textLeaves(alt ?? nonEmpty(href) ?? url, marks, container), ...imageAside((text) => linkedLeaves(text, url, marks, container))]
|
||||
}
|
||||
|
||||
function noteLeaves(name: string): AdfNode[] {
|
||||
|
||||
Reference in New Issue
Block a user