diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index ad415fa..dc00c5c 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -16,18 +16,22 @@ type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number } type Pairing = EmphasisPairing -type Piece = Bracket | { kind: 'nodes'; nodes: AdfNode[] } | { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number } +type Piece = + | Bracket + | { alt: string; kind: 'image'; node: AdfNode } + | { kind: 'nodes'; nodes: AdfNode[] } + | { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number } type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number } -type Scan = { definitions: LinkDefinitions; image: AdfNode | undefined; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string } +type Scan = { definitions: LinkDefinitions; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string } const hardBreakSpaces = / {2,}$/ const imageAlone = 'an image fits only as a paragraph of its own' const trailingSpace = /[ \t]+$/ export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result { - const scan: Scan = { definitions, image: undefined, path, pending: '', pieces: [], source } + const scan: Scan = { definitions, path, pending: '', pieces: [], source } let index = 0 while (index < source.length) { switch (source.charAt(index)) { @@ -138,18 +142,20 @@ function pushNode(scan: Scan, node: AdfNode): void { } function assemble(scan: Scan): Result { - if (scan.image !== undefined) { - if (scan.pieces.length > 0) return failure('unmappable-image', imageAlone, scan.path) - return success({ image: scan.image }) - } + const only = scan.pieces[0] + if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node }) + if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path) return success({ nodes: resolveNodes(scan.pieces) }) } +function holdsImage(pieces: readonly Piece[]): boolean { + return pieces.some((piece) => piece.kind === 'image') +} + function readDelimiterRun(scan: Scan, index: number): number { const character = scan.source.charAt(index) const length = runLength(scan.source, index) const flags = delimiterFlags(character, scan.source.charAt(index - 1), scan.source.charAt(index + length)) - // spec/flavour.md: strike is exactly two tildes. if ((character === '~' && length !== 2) || (!flags.canOpen && !flags.canClose)) scan.pending += scan.source.slice(index, index + length) else { flush(scan, false) @@ -175,16 +181,16 @@ function closeBracket(scan: Scan, index: number): Result { const open = lastBracket(scan.pieces) if (open === undefined) return success(literalClose(scan, index)) const target = open.bracket.active ? resolveTarget(scan, open.bracket, index) : undefined - if (target === undefined) { - scan.pieces[open.index] = { kind: 'nodes', nodes: bracketNodes(open.bracket) } - return success(literalClose(scan, index)) - } - const inner = scan.pieces.splice(open.index).slice(1) + if (target === undefined) return success(unopened(scan, open, index)) + const inner = scan.pieces.slice(open.index + 1) if (open.bracket.image) { - const built = buildImage(scan, inner, target.definition) - if (!built.ok) return built - } else buildLink(scan, inner, target.definition) - return success(index + 1 + target.length) + const closed = closeImage(scan, open.index, inner, target.definition) + if (!closed.ok) return closed + return success(index + 1 + target.length) + } + const closed = closeLink(scan, open.index, inner, target.definition) + if (!closed.ok) return closed + return success(closed.value ? index + 1 + target.length : unopened(scan, open, index)) } function literalClose(scan: Scan, index: number): number { @@ -192,6 +198,11 @@ function literalClose(scan: Scan, index: number): number { return index + 1 } +function unopened(scan: Scan, open: { bracket: Bracket; index: number }, index: number): number { + scan.pieces[open.index] = { kind: 'nodes', nodes: bracketNodes(open.bracket) } + return literalClose(scan, index) +} + function bracketNodes(bracket: Bracket): AdfNode[] { return [{ text: bracket.image ? '![' : '[', type: 'text' }] } @@ -217,27 +228,37 @@ function resolveTarget(scan: Scan, bracket: Bracket, index: number): { definitio return { definition, length: label?.length ?? 0 } } -function buildLink(scan: Scan, inner: readonly Piece[], definition: LinkDefinition): void { +// `false` where the link text is empty: the mark has no node to ride, so the brackets stay text. +function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result { + if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path) + const nodes = resolveNodes(inner) + if (nodes.length === 0) return success(false) const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title } - const nodes = applyMark(resolveNodes(inner), { attrs, type: 'link' }) - // CommonMark: no link nests inside another. - for (const piece of scan.pieces) if (piece.kind === 'open') piece.active = false - scan.pieces.push({ kind: 'nodes', nodes }) + scan.pieces.length = at + // CommonMark: no link nests inside another, though an image's description holds one. + for (const piece of scan.pieces) if (piece.kind === 'open' && !piece.image) piece.active = false + scan.pieces.push({ kind: 'nodes', nodes: applyMark(nodes, { attrs, type: 'link' }) }) + return success(true) } -function buildImage(scan: Scan, inner: readonly Piece[], definition: LinkDefinition): Result { +function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result { if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path) - if (scan.pieces.length > 0 || scan.image !== undefined) return failure('unmappable-image', imageAlone, scan.path) - const alt = resolveNodes(inner) - .map((node) => node.text ?? '') - .join('') + const alt = imageAlt(inner) const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination } - scan.image = { attrs: { layout: 'center' }, content: [{ attrs, type: 'media' }], type: 'mediaSingle' } + scan.pieces.length = at + scan.pieces.push({ alt, kind: 'image', node: { attrs: { layout: 'center' }, content: [{ attrs, type: 'media' }], type: 'mediaSingle' } }) return success(null) } +// spec/flavour.md: the description's plain-text content, where a break of either kind reads as a space. +function imageAlt(inner: readonly Piece[]): string { + return resolveNodes(inner) + .map((node) => (node.type === 'hardBreak' ? ' ' : (node.text ?? ''))) + .join('') +} + function resolveNodes(pieces: readonly Piece[]): AdfNode[] { - const nodes = pieces.map((piece) => (piece.kind === 'nodes' ? piece.nodes : piece.kind === 'open' ? bracketNodes(piece) : [])) + const nodes = pieces.map(pieceNodes) const runs = delimiterRuns(pieces) const pairings = matchEmphasis(runs) writeUnpaired(nodes, runs, pairings) @@ -245,6 +266,14 @@ function resolveNodes(pieces: readonly Piece[]): AdfNode[] { return mergeAdjacentText(nodes.flat()) } +// Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first. +function pieceNodes(piece: Piece): AdfNode[] { + if (piece.kind === 'nodes') return piece.nodes + if (piece.kind === 'open') return bracketNodes(piece) + if (piece.kind === 'image') return piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }] + return [] +} + function delimiterRuns(pieces: readonly Piece[]): Run[] { const runs: Run[] = [] for (const [index, piece] of pieces.entries()) { diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index dc4ba46..3be28cd 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -443,6 +443,22 @@ test('reads a lone image as the media the flavour spells for it', () => { assert.deepEqual(content(markdownToAdf('- ![a](/u)\n')), [bulletList(item(image('/u', 'a')))]) }) +test('flattens the description of a lone image to the plain text alt holds', () => { + assert.deepEqual(content(markdownToAdf('![a [b](/u) c](/v)\n')), [image('/v', 'a b c')]) + assert.deepEqual(content(markdownToAdf('![a ![b](/c) d](/e)\n')), [image('/e', 'a b d')]) + assert.deepEqual(content(markdownToAdf('![a\nb](/u)\n')), [image('/u', 'a b')]) + assert.deepEqual(content(markdownToAdf('![a \nb](/u)\n')), [image('/u', 'a b')]) + assert.deepEqual(content(markdownToAdf('![a `b`](/u)\n')), [image('/u', 'a b')]) +}) + +test('leaves the brackets of an empty link text the text they are', () => { + assert.deepEqual(content(markdownToAdf('[](/u)\n')), [paragraph('[](/u)')]) + assert.deepEqual(content(markdownToAdf('a [](/u) b\n')), [paragraph('a [](/u) b')]) + // The pair gives the label back the way an unresolved one does, so the shortcut behind it still reads. + assert.deepEqual(content(markdownToAdf('[][r]\n\n[r]: /u\n')), [{ content: [text('[]'), marked('r', link('/u'))], type: 'paragraph' }]) + assert.deepEqual(content(markdownToAdf('![](/u)\n')), [image('/u')]) +}) + test('refuses the image no ADF node carries where it sits', () => { assert.equal(content(markdownToAdf('![a](/u "t")\n')), 'unmappable-image: no media node carries a link title') assert.equal(content(markdownToAdf('See ![a](/u).\n')), 'unmappable-image: an image fits only as a paragraph of its own') @@ -450,6 +466,7 @@ test('refuses the image no ADF node carries where it sits', () => { assert.equal(code(markdownToAdf('*![a](/u)*\n')), 'unmappable-image') assert.equal(code(markdownToAdf('[![a](/u)](/v)\n')), 'unmappable-image') assert.equal(code(markdownToAdf('![a](/u)![b](/v)\n')), 'unmappable-image') + assert.equal(code(markdownToAdf('![a ![b](/c) d\n')), 'unmappable-image') assert.deepEqual(path(markdownToAdf('Part.\n\nSee ![a](/u).\n')), ['content', 1]) assert.deepEqual(content(markdownToAdf('![a]\n')), [paragraph('![a]')]) assert.deepEqual(content(markdownToAdf('a ! b\n')), [paragraph('a ! b')]) diff --git a/todo.md b/todo.md index 757be72..dcb2ff0 100644 --- a/todo.md +++ b/todo.md @@ -260,6 +260,14 @@ detail is settled at its own milestone. no ADF node carries. And the CommonMark image shape lands here rather than at 3h: once `[…](…)` reads, a lone `![alt](url)` would otherwise misparse as text plus a link, so 3h keeps the rest of the media family and loses only that line. + **Settled** (the maintainer, 2026-08-31, on the review): an empty link text — `[](/u)` — + leaves the brackets the text they are rather than minting a refusal or dropping the + destination, giving the label back the way an unresolved pair does, so the shortcut behind + `[][r]` still reads. A description holding an image flattens to that image's own alt, which + is what alt text means and what keeps the documented gap to mid-text and titled images; a + break of either kind inside one reads as a space. And a destination or title whose entity + reference decodes to a control character — `[a](/x y)` — joins 3k's exception list + beside the two above: the reader takes cmark's reading, the emitter has no spelling for it. - [ ] **3f — The directive grammar.** The three forms — inline `:name[content]{attrs}`, container `:::name arg {attrs}`, leaf `::name arg {attrs}` — the attribute grammar with its quoting and escapes, the fence-length and nesting rules, and the malformed list