Name the node every refusal came from, and close the error list
CI / gate (push) Successful in 4s

This commit is contained in:
2026-08-25 09:00:47 +02:00
parent e92484eb85
commit e2ea868ac6
9 changed files with 165 additions and 113 deletions
+62 -53
View File
@@ -1,7 +1,7 @@
import type { AdfMark, AdfNode } from './adf-document.ts'
import { assembleInlineLine, type InlineSegment, type LineContainer } from './markdown-escaping.ts'
import { claimsLine, holdsEntityReference } from './commonmark-grammar.ts'
import { failure, success, type Result } from './result.ts'
import { claimsLine, holdsControlCharacter, holdsEntityReference, isAutolink } from './commonmark-grammar.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { longestBacktickRun } from './backtick-runs.ts'
import { serializeCanonicalJson } from './canonical-json.ts'
@@ -9,97 +9,103 @@ type InlineContext = {
atBlockEnd: boolean
container: LineContainer
inLinkText: boolean
path: ConvertErrorPath
}
type InlineRun = { kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { kind: 'plain'; node: AdfNode }
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
const autolink = /^[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\s<>\u0000-\u001f\u007f]*$/
const controlCharacter = /[\u0000-\u001f\u007f]/
const linkAttributes = ['href', 'title']
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer): Result<string> {
const segments = emitRun(nodes, 0, { atBlockEnd: true, container, inLinkText: false })
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
const segments = emitRun(nodes, 0, 0, { atBlockEnd: true, container, inLinkText: false, path })
if (!segments.ok) return segments
const line = assembleInlineLine(segments.value, container)
for (const [index, single] of line.split('\n').entries()) {
if (/^[ \t]|[ \t]$/.test(single)) {
return failure('unspellable-whitespace', 'a line begins or ends with whitespace CommonMark strips')
return failure('unspellable-whitespace', 'a line begins or ends with whitespace CommonMark strips', path)
}
if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) {
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(single)}`)
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(single)}`, path)
}
}
return success(line)
}
function emitRun(nodes: readonly AdfNode[], depth: number, context: InlineContext): Result<InlineSegment[]> {
const runs = inlineRuns(nodes, depth)
function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): Result<InlineSegment[]> {
const runs = inlineRuns(nodes, depth, firstIndex)
const segments: InlineSegment[] = []
for (const [index, run] of runs.entries()) {
const runContext = { ...context, atBlockEnd: context.atBlockEnd && index === runs.length - 1 }
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext) : emitMarkedRun(run.nodes, run.mark, depth, runContext)
for (const [offset, run] of runs.entries()) {
const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 }
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
if (!emitted.ok) return emitted
segments.push(...emitted.value)
}
return success(segments)
}
function inlineRuns(nodes: readonly AdfNode[], depth: number): InlineRun[] {
function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number): InlineRun[] {
const runs: InlineRun[] = []
for (const node of nodes) {
for (const [offset, node] of nodes.entries()) {
const index = firstIndex + offset
const mark = (node.marks ?? [])[depth]
if (mark === undefined) {
runs.push({ kind: 'plain', node })
runs.push({ index, kind: 'plain', node })
continue
}
const previous = runs[runs.length - 1]
if (previous?.kind === 'marked' && sameMark(previous.mark, mark)) previous.nodes.push(node)
else runs.push({ kind: 'marked', mark, nodes: [node] })
else runs.push({ index, kind: 'marked', mark, nodes: [node] })
}
return runs
}
function emitLeaf(node: AdfNode, context: InlineContext): Result<InlineSegment[]> {
function nodePath(context: InlineContext, index: number): ConvertErrorPath {
return [...context.path, 'content', index]
}
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<InlineSegment[]> {
const path = nodePath(context, index)
if (node.type !== 'hardBreak' && node.type !== 'text') {
return failure('unsupported-node-type', `the canonical form spells no inline node of type ${node.type}`)
return failure('unsupported-node-type', `the canonical form spells no inline node of type ${node.type}`, path)
}
const unspelled = Object.keys(node.attrs ?? {})[0]
if (unspelled !== undefined) {
return failure('unspelled-node-attribute', `the ${node.type} attribute ${unspelled} has no canonical markdown spelling`)
return failure('unspelled-node-attribute', `the ${node.type} attribute ${unspelled} has no canonical markdown spelling`, path)
}
if (node.type === 'hardBreak') {
if (context.container === 'heading' || context.atBlockEnd) return success([{ kind: 'syntax', text: ':hardBreak{}' }])
return success([{ kind: 'syntax', text: '\\\n' }])
}
if (typeof node.text !== 'string') return failure('unsupported-node-shape', 'a text node carries no text')
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node carries content')
if (/[\n\r]/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a newline CommonMark cannot spell')
if (typeof node.text !== 'string') return failure('unsupported-node-shape', 'a text node carries no text', path)
if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a text node carries content', path)
if (/[\n\r]/.test(node.text)) return failure('unspellable-whitespace', 'a text node holds a newline CommonMark cannot spell', path)
return success([{ kind: context.inLinkText ? 'link-text' : 'literal', text: node.text }])
}
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, context: InlineContext): Result<InlineSegment[]> {
if (mark.type === 'code') return emitCodeSpan(nodes, depth)
if (mark.type === 'link') return emitLink(nodes, mark, depth, context)
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<InlineSegment[]> {
if (mark.type === 'code') return emitCodeSpan(nodes, depth, nodePath(context, index))
if (mark.type === 'link') return emitLink(nodes, mark, depth, index, context)
const path = nodePath(context, index)
const spelling = mark.type === 'em' ? '_' : mark.type === 'strike' ? '~~' : mark.type === 'strong' ? '**' : undefined
if (spelling === undefined) return failure('unspellable-mark', `no markdown spelling holds the ${mark.type} mark`)
if (Object.keys(mark.attrs ?? {}).length > 0) return failure('unspellable-mark', `the ${mark.type} spelling holds no attributes`)
const inner = emitRun(nodes, depth + 1, context)
if (spelling === undefined) return failure('unspellable-mark', `no markdown spelling holds the ${mark.type} mark`, path)
if (Object.keys(mark.attrs ?? {}).length > 0) return failure('unspellable-mark', `the ${mark.type} spelling holds no attributes`, path)
const inner = emitRun(nodes, depth + 1, index, context)
if (!inner.ok) return inner
const text = inner.value.map((segment) => segment.text).join('')
if (/^[ \t]|[ \t]$/.test(text)) return failure('unspellable-whitespace', `the ${mark.type} spelling cannot open or close beside whitespace`)
if (/^[ \t]|[ \t]$/.test(text)) return failure('unspellable-whitespace', `the ${mark.type} spelling cannot open or close beside whitespace`, path)
if (mark.type === 'em') {
return success([{ kind: 'emphasis-open', text: spelling }, ...inner.value, { kind: 'emphasis-close', text: spelling }])
}
return success([{ kind: 'syntax', text: spelling }, ...inner.value, { kind: 'syntax', text: spelling }])
}
function emitCodeSpan(nodes: readonly AdfNode[], depth: number): Result<InlineSegment[]> {
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, path: ConvertErrorPath): Result<InlineSegment[]> {
const node = nodes[0]
if (nodes.length !== 1 || node === undefined || node.type !== 'text' || typeof node.text !== 'string') {
return failure('unspellable-mark', 'a code span holds exactly one text node')
return failure('unspellable-mark', 'a code span holds exactly one text node', path)
}
if ((node.marks ?? []).length !== depth + 1) return failure('unspellable-mark', 'a code span cannot sit inside the marks it carries')
if (/[\n\r]/.test(node.text)) return failure('unspellable-mark', 'a code span holds no newline')
if ((node.marks ?? []).length !== depth + 1) return failure('unspellable-mark', 'a code span cannot sit inside the marks it carries', path)
if (/[\n\r]/.test(node.text)) return failure('unspellable-mark', 'a code span holds no newline', path)
const fence = '`'.repeat(longestBacktickRun(node.text) + 1)
const padded = needsPadding(node.text) ? ` ${node.text} ` : node.text
return success([{ kind: 'syntax', text: `${fence}${padded}${fence}` }])
@@ -110,45 +116,48 @@ function needsPadding(text: string): boolean {
return text.startsWith(' ') && text.endsWith(' ') && text.trim() !== ''
}
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, context: InlineContext): Result<InlineSegment[]> {
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<InlineSegment[]> {
const path = nodePath(context, index)
const unspelled = Object.keys(mark.attrs ?? {}).find((key) => !linkAttributes.includes(key))
if (unspelled !== undefined) return failure('unspellable-mark', `the link spelling holds no ${unspelled} attribute`)
if (unspelled !== undefined) return failure('unspellable-mark', `the link spelling holds no ${unspelled} attribute`, path)
const href = mark.attrs?.['href']
const title = mark.attrs?.['title']
if (typeof href !== 'string') return failure('unsupported-node-shape', 'a link mark carries no href')
if (title !== undefined && typeof title !== 'string') return failure('unsupported-node-shape', 'a link title is no string')
if (typeof href !== 'string') return failure('unsupported-node-shape', 'a link mark carries no href', path)
if (title !== undefined && typeof title !== 'string') return failure('unsupported-node-shape', 'a link title is no string', path)
const node = nodes[0]
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && (node.marks ?? []).length === depth + 1
if (bare && title === undefined && autolink.test(href)) return success([{ kind: 'syntax', text: `<${href}>` }])
const destination = spellDestination(href)
if (bare && title === undefined && isAutolink(href)) return success([{ kind: 'syntax', text: `<${href}>` }])
const destination = spellDestination(href, path)
if (!destination.ok) return destination
const spelledTitle = title === undefined ? success('') : spellTitle(title)
const spelledTitle = title === undefined ? success('') : spellTitle(title, path)
if (!spelledTitle.ok) return spelledTitle
const inner = emitRun(nodes, depth + 1, { ...context, inLinkText: true })
const inner = emitRun(nodes, depth + 1, index, { ...context, inLinkText: true })
if (!inner.ok) return inner
return success([{ kind: 'syntax', text: '[' }, ...inner.value, { kind: 'syntax', text: `](${destination.value}${spelledTitle.value})` }])
}
function spellDestination(href: string): Result<string> {
if (controlCharacter.test(href)) return failure('unspellable-link-destination', 'a link destination holds a control character')
if (href.includes('\\')) return failure('unspellable-link-destination', 'no canonical escape spells a backslash in a link destination')
function spellDestination(href: string, path: ConvertErrorPath): Result<string> {
if (holdsControlCharacter(href)) return failure('unspellable-link-destination', 'a link destination holds a control character', path)
if (href.includes('\\')) return failure('unspellable-link-destination', 'no canonical escape spells a backslash in a link destination', path)
if (holdsEntityReference(href)) {
return failure('unspellable-link-destination', 'a link destination shaped like an entity reference decodes on the way back')
return failure('unspellable-link-destination', 'a link destination shaped like an entity reference decodes on the way back', path)
}
if (href.includes(' ')) {
if (/[<>]/.test(href)) {
return failure('unspellable-link-destination', 'no canonical escape spells an angle bracket beside a space in a link destination')
return failure('unspellable-link-destination', 'no canonical escape spells an angle bracket beside a space in a link destination', path)
}
return success(`<${href}>`)
}
if (href.startsWith('<')) return failure('unspellable-link-destination', 'a bare link destination cannot begin with an angle bracket')
if (!balanced(href)) return failure('unspellable-link-destination', 'no canonical escape spells an unbalanced parenthesis in a link destination')
if (href.startsWith('<')) return failure('unspellable-link-destination', 'a bare link destination cannot begin with an angle bracket', path)
if (!balanced(href)) return failure('unspellable-link-destination', 'no canonical escape spells an unbalanced parenthesis in a link destination', path)
return success(href)
}
function spellTitle(title: string): Result<string> {
if (/["\n\r\\]/.test(title)) return failure('unspellable-link-title', 'no canonical escape spells a quote, backslash or newline in a link title')
if (holdsEntityReference(title)) return failure('unspellable-link-title', 'a link title shaped like an entity reference decodes on the way back')
function spellTitle(title: string, path: ConvertErrorPath): Result<string> {
if (/["\n\r\\]/.test(title)) {
return failure('unspellable-link-title', 'no canonical escape spells a quote, backslash or newline in a link title', path)
}
if (holdsEntityReference(title)) return failure('unspellable-link-title', 'a link title shaped like an entity reference decodes on the way back', path)
return success(` "${title}"`)
}