Refuse the nested lists a tight spelling swallows, and name the mark that failed
CI / gate (push) Successful in 4s

This commit is contained in:
2026-08-25 10:02:22 +02:00
parent 91d5cd6c43
commit 480176fd21
5 changed files with 52 additions and 17 deletions
+14
View File
@@ -193,9 +193,23 @@ test('refuses a mark spelling that cannot open or close where it sits', () => {
const em: AdfMark = { type: 'em' }
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, marked('a.', em), marked('b', strong))))), 'unspellable-mark')
assert.equal(emitted({ text: 'x', type: 'text' }, marked('ab', em, strong), { text: 'y', type: 'text' }), 'x***ab***y\n')
})
test('refuses a node carrying one mark type twice', () => {
const em: AdfMark = { type: 'em' }
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [em, em], text: 'x', type: 'text' })))), 'unsupported-node-shape')
})
test('refuses a nested list the tight spelling would swallow', () => {
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' })
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' })
const ordered: AdfNode = { attrs: { order: 2 }, content: [item(text('b'))], type: 'orderedList' }
assert.equal(code(adfToMarkdown(outer(text('a'), ordered))), 'unspellable-line-start')
assert.equal(code(adfToMarkdown(outer(text('a'), { content: [item()], type: 'bulletList' }))), 'unspellable-line-start')
assert.equal(markdown(adfToMarkdown(outer(text('a'), { content: [item(text('b'))], type: 'bulletList' }))), '- a\n - b\n')
})
test('refuses marks and attributes nested deeper than the emitter carries', () => {
const marks: AdfMark[] = Array.from({ length: 600 }, (_, index) => ({ type: index % 2 === 0 ? 'em' : 'strong' }))
assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-node-shape')
+11 -1
View File
@@ -28,7 +28,12 @@ function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean, path: Conver
if (listTypes.includes(node.type) && previous.type === node.type) {
return failure('unspellable-adjacent-lists', `two adjacent ${node.type} nodes read back as one list`, nodePath)
}
output += inListItem && listTypes.includes(node.type) ? '\n' : '\n\n'
if (inListItem && listTypes.includes(node.type)) {
if (!interruptsParagraph(node)) {
return failure('unspellable-line-start', `a ${node.type} that cannot interrupt the block above it has no tight spelling`, nodePath)
}
output += '\n'
} else output += '\n\n'
}
const block = emitBlock(node, nodePath, depth)
if (!block.ok) return block
@@ -38,6 +43,11 @@ function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean, path: Conver
return success(output)
}
function interruptsParagraph(node: AdfNode): boolean {
if (node.type === 'orderedList') return false
return ((node.content ?? [])[0]?.content ?? []).length > 0
}
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<string> {
if (node.type === 'blockquote') return emitBlockquote(node, path, depth)
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth)
+15 -12
View File
@@ -1,16 +1,14 @@
import { escapesLineClaim, isUnicodeWhitespace, opensBracketedAutolink, startsEntityReference, type LinePosition } from './commonmark-grammar.ts'
export type InlineSegment = {
kind: 'emphasis-close' | 'emphasis-open' | 'link-text' | 'literal' | 'syntax'
mark?: string
text: string
}
export type InlineSegment =
| { kind: 'emphasis-close' | 'emphasis-open'; mark: string; text: string }
| { kind: 'link-text' | 'literal' | 'syntax'; text: string }
export type AssembledLine = { line: string; unspellableMark: string | undefined }
export type LineContainer = 'heading' | 'paragraph'
type DelimiterRun = { character: string; closes: boolean; end: number; mark: string; opens: boolean; start: number }
type DelimiterRun = { character: string; closeMark: string | undefined; end: number; openMark: string | undefined; start: number }
const delimiters = ['*', '_', '`', '~']
@@ -77,8 +75,8 @@ function unspellableMark(segments: readonly InlineSegment[], output: string, pla
for (const run of delimiterRuns(segments, placements)) {
const before = charAt(output, run.start - 1)
const after = output.charAt(run.end)
if (run.opens && !isLeftFlanking(before, after)) return run.mark
if (run.closes && !isRightFlanking(before, after)) return run.mark
if (run.openMark !== undefined && !isLeftFlanking(before, after)) return run.openMark
if (run.closeMark !== undefined && !isRightFlanking(before, after)) return run.closeMark
}
return undefined
}
@@ -92,15 +90,20 @@ function delimiterRuns(segments: readonly InlineSegment[], placements: readonly
if (segment.kind !== 'emphasis-close' && segment.kind !== 'emphasis-open') continue
const closes = segment.kind === 'emphasis-close'
const end = start + segment.text.length
const mark = segment.mark ?? segment.text
const previous = runs[runs.length - 1]
if (previous !== undefined && previous.end === start && previous.character === segment.text.charAt(0)) {
previous.closes = previous.closes || closes
previous.closeMark = previous.closeMark ?? (closes ? segment.mark : undefined)
previous.end = end
previous.opens = previous.opens || !closes
previous.openMark = previous.openMark ?? (closes ? undefined : segment.mark)
continue
}
runs.push({ character: segment.text.charAt(0), closes, end, mark, opens: !closes, start })
runs.push({
character: segment.text.charAt(0),
closeMark: closes ? segment.mark : undefined,
end,
openMark: closes ? undefined : segment.mark,
start,
})
}
return runs
}