Keep an image's description whole, and the brackets of an empty link text literal
CI / gate (push) Successful in 5s
CI / gate (push) Successful in 5s
This commit is contained in:
@@ -16,18 +16,22 @@ type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
|
|||||||
|
|
||||||
type Pairing = EmphasisPairing<Run>
|
type Pairing = EmphasisPairing<Run>
|
||||||
|
|
||||||
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 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 hardBreakSpaces = / {2,}$/
|
||||||
const imageAlone = 'an image fits only as a paragraph of its own'
|
const imageAlone = 'an image fits only as a paragraph of its own'
|
||||||
const trailingSpace = /[ \t]+$/
|
const trailingSpace = /[ \t]+$/
|
||||||
|
|
||||||
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<InlineContent> {
|
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<InlineContent> {
|
||||||
const scan: Scan = { definitions, image: undefined, path, pending: '', pieces: [], source }
|
const scan: Scan = { definitions, path, pending: '', pieces: [], source }
|
||||||
let index = 0
|
let index = 0
|
||||||
while (index < source.length) {
|
while (index < source.length) {
|
||||||
switch (source.charAt(index)) {
|
switch (source.charAt(index)) {
|
||||||
@@ -138,18 +142,20 @@ function pushNode(scan: Scan, node: AdfNode): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function assemble(scan: Scan): Result<InlineContent> {
|
function assemble(scan: Scan): Result<InlineContent> {
|
||||||
if (scan.image !== undefined) {
|
const only = scan.pieces[0]
|
||||||
if (scan.pieces.length > 0) return failure('unmappable-image', imageAlone, scan.path)
|
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
|
||||||
return success({ image: scan.image })
|
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
|
||||||
}
|
|
||||||
return success({ nodes: resolveNodes(scan.pieces) })
|
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 {
|
function readDelimiterRun(scan: Scan, index: number): number {
|
||||||
const character = scan.source.charAt(index)
|
const character = scan.source.charAt(index)
|
||||||
const length = runLength(scan.source, index)
|
const length = runLength(scan.source, index)
|
||||||
const flags = delimiterFlags(character, scan.source.charAt(index - 1), scan.source.charAt(index + length))
|
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)
|
if ((character === '~' && length !== 2) || (!flags.canOpen && !flags.canClose)) scan.pending += scan.source.slice(index, index + length)
|
||||||
else {
|
else {
|
||||||
flush(scan, false)
|
flush(scan, false)
|
||||||
@@ -175,23 +181,28 @@ function closeBracket(scan: Scan, index: number): Result<number> {
|
|||||||
const open = lastBracket(scan.pieces)
|
const open = lastBracket(scan.pieces)
|
||||||
if (open === undefined) return success(literalClose(scan, index))
|
if (open === undefined) return success(literalClose(scan, index))
|
||||||
const target = open.bracket.active ? resolveTarget(scan, open.bracket, index) : undefined
|
const target = open.bracket.active ? resolveTarget(scan, open.bracket, index) : undefined
|
||||||
if (target === undefined) {
|
if (target === undefined) return success(unopened(scan, open, index))
|
||||||
scan.pieces[open.index] = { kind: 'nodes', nodes: bracketNodes(open.bracket) }
|
const inner = scan.pieces.slice(open.index + 1)
|
||||||
return success(literalClose(scan, index))
|
|
||||||
}
|
|
||||||
const inner = scan.pieces.splice(open.index).slice(1)
|
|
||||||
if (open.bracket.image) {
|
if (open.bracket.image) {
|
||||||
const built = buildImage(scan, inner, target.definition)
|
const closed = closeImage(scan, open.index, inner, target.definition)
|
||||||
if (!built.ok) return built
|
if (!closed.ok) return closed
|
||||||
} else buildLink(scan, inner, target.definition)
|
|
||||||
return success(index + 1 + target.length)
|
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 {
|
function literalClose(scan: Scan, index: number): number {
|
||||||
scan.pending += ']'
|
scan.pending += ']'
|
||||||
return index + 1
|
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[] {
|
function bracketNodes(bracket: Bracket): AdfNode[] {
|
||||||
return [{ text: bracket.image ? '![' : '[', type: 'text' }]
|
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 }
|
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<boolean> {
|
||||||
|
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 attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title }
|
||||||
const nodes = applyMark(resolveNodes(inner), { attrs, type: 'link' })
|
scan.pieces.length = at
|
||||||
// CommonMark: no link nests inside another.
|
// CommonMark: no link nests inside another, though an image's description holds one.
|
||||||
for (const piece of scan.pieces) if (piece.kind === 'open') piece.active = false
|
for (const piece of scan.pieces) if (piece.kind === 'open' && !piece.image) piece.active = false
|
||||||
scan.pieces.push({ kind: 'nodes', nodes })
|
scan.pieces.push({ kind: 'nodes', nodes: applyMark(nodes, { attrs, type: 'link' }) })
|
||||||
|
return success(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildImage(scan: Scan, inner: readonly Piece[], definition: LinkDefinition): Result<null> {
|
function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<null> {
|
||||||
if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path)
|
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 = imageAlt(inner)
|
||||||
const alt = resolveNodes(inner)
|
|
||||||
.map((node) => node.text ?? '')
|
|
||||||
.join('')
|
|
||||||
const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination }
|
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)
|
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[] {
|
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 runs = delimiterRuns(pieces)
|
||||||
const pairings = matchEmphasis(runs)
|
const pairings = matchEmphasis(runs)
|
||||||
writeUnpaired(nodes, runs, pairings)
|
writeUnpaired(nodes, runs, pairings)
|
||||||
@@ -245,6 +266,14 @@ function resolveNodes(pieces: readonly Piece[]): AdfNode[] {
|
|||||||
return mergeAdjacentText(nodes.flat())
|
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[] {
|
function delimiterRuns(pieces: readonly Piece[]): Run[] {
|
||||||
const runs: Run[] = []
|
const runs: Run[] = []
|
||||||
for (const [index, piece] of pieces.entries()) {
|
for (const [index, piece] of pieces.entries()) {
|
||||||
|
|||||||
@@ -443,6 +443,22 @@ test('reads a lone image as the media the flavour spells for it', () => {
|
|||||||
assert.deepEqual(content(markdownToAdf('- \n')), [bulletList(item(image('/u', 'a')))])
|
assert.deepEqual(content(markdownToAdf('- \n')), [bulletList(item(image('/u', 'a')))])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('flattens the description of a lone image to the plain text alt holds', () => {
|
||||||
|
assert.deepEqual(content(markdownToAdf(' c](/v)\n')), [image('/v', 'a b c')])
|
||||||
|
assert.deepEqual(content(markdownToAdf(' d](/e)\n')), [image('/e', 'a b d')])
|
||||||
|
assert.deepEqual(content(markdownToAdf('\n')), [image('/u', 'a b')])
|
||||||
|
assert.deepEqual(content(markdownToAdf('\n')), [image('/u', 'a b')])
|
||||||
|
assert.deepEqual(content(markdownToAdf('\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('\n')), [image('/u')])
|
||||||
|
})
|
||||||
|
|
||||||
test('refuses the image no ADF node carries where it sits', () => {
|
test('refuses the image no ADF node carries where it sits', () => {
|
||||||
assert.equal(content(markdownToAdf('\n')), 'unmappable-image: no media node carries a link title')
|
assert.equal(content(markdownToAdf('\n')), 'unmappable-image: no media node carries a link title')
|
||||||
assert.equal(content(markdownToAdf('See .\n')), 'unmappable-image: an image fits only as a paragraph of its own')
|
assert.equal(content(markdownToAdf('See .\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('**\n')), 'unmappable-image')
|
assert.equal(code(markdownToAdf('**\n')), 'unmappable-image')
|
||||||
assert.equal(code(markdownToAdf('[](/v)\n')), 'unmappable-image')
|
assert.equal(code(markdownToAdf('[](/v)\n')), 'unmappable-image')
|
||||||
assert.equal(code(markdownToAdf('\n')), 'unmappable-image')
|
assert.equal(code(markdownToAdf('\n')), 'unmappable-image')
|
||||||
|
assert.equal(code(markdownToAdf(' d\n')), 'unmappable-image')
|
||||||
assert.deepEqual(path(markdownToAdf('Part.\n\nSee .\n')), ['content', 1])
|
assert.deepEqual(path(markdownToAdf('Part.\n\nSee .\n')), ['content', 1])
|
||||||
assert.deepEqual(content(markdownToAdf('![a]\n')), [paragraph('![a]')])
|
assert.deepEqual(content(markdownToAdf('![a]\n')), [paragraph('![a]')])
|
||||||
assert.deepEqual(content(markdownToAdf('a ! b\n')), [paragraph('a ! b')])
|
assert.deepEqual(content(markdownToAdf('a ! b\n')), [paragraph('a ! b')])
|
||||||
|
|||||||
@@ -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
|
no ADF node carries. And the CommonMark image shape lands here rather than at 3h: once
|
||||||
`[…](…)` reads, a lone `` would otherwise misparse as text plus a link, so 3h
|
`[…](…)` reads, a lone `` would otherwise misparse as text plus a link, so 3h
|
||||||
keeps the rest of the media family and loses only that line.
|
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}`,
|
- [ ] **3f — The directive grammar.** The three forms — inline `:name[content]{attrs}`,
|
||||||
container `:::name arg {attrs}`, leaf `::name arg {attrs}` — the attribute grammar with
|
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
|
its quoting and escapes, the fence-length and nesting rules, and the malformed list
|
||||||
|
|||||||
Reference in New Issue
Block a user