13b - link takes the directive form where CommonMark cannot spell it, unspellable-link leaves the code list
CI / gate (push) Successful in 26s
CI / publish (push) Has been skipped

This commit is contained in:
2026-09-16 23:09:30 +02:00
parent d282470efa
commit 9fe5d198d1
50 changed files with 662 additions and 141 deletions
+27 -16
View File
@@ -82,15 +82,25 @@ test('spells a code block language no info string holds as an attribute', () =>
assert.equal(language('a\tb'), '!adf:codeBlock {language="a\\tb"}\n```\n```\n!adf:/codeBlock\n')
})
test('refuses a link destination CommonMark cannot spell', () => {
const link = (href: string): AdfDocument => document(paragraph({ marks: [{ attrs: { href }, type: 'link' }], text: 't', type: 'text' }))
assert.equal(code(adfToMarkdown(link('https://example.com/a b>c'))), 'unspellable-link')
assert.equal(code(adfToMarkdown(link('<https://example.com/'))), 'unspellable-link')
assert.equal(code(adfToMarkdown(link('https://example.com/a\\b'))), 'unspellable-link')
assert.equal(code(adfToMarkdown(link('https://example.com/?a=1&amp;b=2'))), 'unspellable-link')
assert.equal(code(adfToMarkdown(link('https://example.com/a\nb'))), 'unspellable-link')
test('spells a link destination CommonMark cannot as the directive link', () => {
const link = (href: string): string => markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href }, type: 'link' }], text: 't', type: 'text' }))))
assert.equal(link('https://example.com/a b>c'), '!adf:link[t]{href="https://example.com/a b>c"}\n')
assert.equal(link('<https://example.com/'), '!adf:link[t]{href="\\u003chttps://example.com/"}\n')
assert.equal(link('https://example.com/a\\b'), '!adf:link[t]{href="https://example.com/a\\\\b"}\n')
assert.equal(link('https://example.com/?a=1&amp;b=2'), '!adf:link[t]{href="https://example.com/?a=1\\u0026amp;b=2"}\n')
assert.equal(link('https://example.com/a\nb'), '!adf:link[t]{href="https://example.com/a\\nb"}\n')
const entity = 'https://example.com/?a=1&amp;b=2'
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: entity }, type: 'link' }], text: entity, type: 'text' })))), 'unspellable-link')
const autolinkShaped = document(paragraph({ marks: [{ attrs: { href: entity }, type: 'link' }], text: entity, type: 'text' }))
assert.equal(markdown(adfToMarkdown(autolinkShaped)), '!adf:link[https://example.com/?a=1\\&amp;b=2]{href="https://example.com/?a=1\\u0026amp;b=2"}\n')
})
test('spells a link carrying an attribute CommonMark cannot as the directive link, and refuses none', () => {
const link = (attrs: Record<string, string>): string =>
markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'link' }], text: 't', type: 'text' }))))
assert.equal(link({ collection: 'c', href: 'https://example.com/' }), '!adf:link[t]{collection=c href="https://example.com/"}\n')
assert.equal(link({ href: 'https://example.com/', id: 'i' }), '!adf:link[t]{href="https://example.com/" id=i}\n')
assert.equal(link({ href: 'https://example.com/', occurrenceKey: 'k' }), '!adf:link[t]{href="https://example.com/" occurrenceKey=k}\n')
assert.equal(link({ href: 'https://example.com/', id: 'i', title: 'a\nb' }), '!adf:link[t]{href="https://example.com/" id=i title="a\\nb"}\n')
})
test('escapes the parenthesis a link destination leaves unbalanced, and no other', () => {
@@ -102,19 +112,20 @@ test('escapes the parenthesis a link destination leaves unbalanced, and no other
assert.equal(link('https://example.com/a (b'), '[t](<https://example.com/a (b>)\n')
})
test('escapes the quote a link title holds, and refuses the rest', () => {
const titled = (title: string): AdfDocument =>
document(paragraph({ marks: [{ attrs: { href: 'https://example.com/', title }, type: 'link' }], text: 't', type: 'text' }))
assert.equal(markdown(adfToMarkdown(titled('He said "hi"'))), '[t](https://example.com/ "He said \\"hi\\"")\n')
assert.equal(code(adfToMarkdown(titled('a\nb'))), 'unspellable-link')
assert.equal(code(adfToMarkdown(titled('a\\b'))), 'unspellable-link')
test('escapes the quote a link title holds, and spells the rest as the directive link', () => {
const titled = (title: string): string =>
markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: 'https://example.com/', title }, type: 'link' }], text: 't', type: 'text' }))))
assert.equal(titled('He said "hi"'), '[t](https://example.com/ "He said \\"hi\\"")\n')
assert.equal(titled('a\nb'), '!adf:link[t]{href="https://example.com/" title="a\\nb"}\n')
assert.equal(titled('a\\b'), '!adf:link[t]{href="https://example.com/" title="a\\\\b"}\n')
assert.equal(titled('a &amp; b'), '!adf:link[t]{href="https://example.com/" title="a \\u0026amp; b"}\n')
})
test('carries a link mark the link spelling cannot write', () => {
const carried = (mark: AdfMark): string => markdown(adfToMarkdown(document(paragraph({ marks: [mark], text: 't', type: 'text' }))))
assert.equal(
carried({ attrs: { href: 'x', id: 'y' }, type: 'link' }),
'!adf:carry{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":\\"x\\",\\"id\\":\\"y\\"},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n',
carried({ attrs: { href: 'x', rel: 'y' }, type: 'link' }),
'!adf:carry{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":\\"x\\",\\"rel\\":\\"y\\"},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n',
)
assert.equal(carried({ attrs: { href: 4 }, type: 'link' }), '!adf:carry{json="{\\"marks\\":[{\\"attrs\\":{\\"href\\":4},\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n')
assert.equal(carried({ type: 'link' }), '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"link\\"}],\\"text\\":\\"t\\",\\"type\\":\\"text\\"}"}\n')
+51 -28
View File
@@ -2,21 +2,20 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { InlineDirective } from '../../adf/inline-directives.ts'
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts'
import { carriedInline } from '../opaque-carry.ts'
import { claimsLine, holdsNullCharacter, isAutolink } from '../commonmark-grammar.ts'
import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../link-syntax.ts'
import { claimsLine, holdsNullCharacter } from '../commonmark-grammar.ts'
import { commonMarkLink, markSpelling, spellMarkAttributes, type CommonMarkLink } from '../mark-spellings.ts'
import { escapeUnbalanced, spellDestination } from '../link-syntax.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { holdsDirectivePrefix, slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
import { holdsEntityReference } from '../entity-references.ts'
import { inlineDirective } from '../../adf/inline-directives.ts'
import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../backtick-runs.ts'
import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { sameMark } from '../../adf/editor-normal.ts'
import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
import { spellTextDirective } from '../text-directive.ts'
type EmittedLine = { line: string; segments: InlineSegment[] }
type EmittedLine = { line: string; openingLinkAsDirective: boolean; segments: InlineSegment[] }
type Emission = { carry: NodeRange; segments?: undefined } | { carry?: undefined; segments: InlineSegment[] }
@@ -24,13 +23,17 @@ type InlineContext = {
atBlockEnd: boolean
bracketed: boolean
carried: ReadonlySet<number>
openingLinkAsDirective: boolean
path: ConvertErrorPath
spansLines: boolean
}
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
type LineAttempt = { carry: NodeRange; line?: undefined } | { carry?: undefined; line: string }
type LineAttempt =
| { carry: NodeRange; line?: undefined; openingLinkAsDirective?: undefined }
| { carry?: undefined; line?: undefined; openingLinkAsDirective: true }
| { carry?: undefined; line: string; openingLinkAsDirective?: undefined }
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
const emitted = emitLine(nodes, container, path)
@@ -38,6 +41,13 @@ export function emitInlineLine(nodes: readonly AdfNode[], container: LineContain
return success(emitted.value.line)
}
// The parser asks which form a paragraph's opening link takes rather than restating the line rule (AGENTS.md §11).
export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<boolean> {
const emitted = emitLine(nodes, 'paragraph', path)
if (!emitted.ok) return emitted
return success(emitted.value.openingLinkAsDirective)
}
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
const emitted = emitLine(nodes, 'table-cell', path)
if (!emitted.ok) return undefined
@@ -47,18 +57,19 @@ export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath):
export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined {
if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined
const destination = spellDestination(href, path)
if (!destination.ok) return undefined
const destination = spellDestination(href)
if (destination === undefined) return undefined
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
const attempt = attemptLine([syntax('!['), ...description, syntax(`](${destination.value})`)], 'paragraph', path)
const attempt = attemptLine([syntax('!['), ...description, syntax(`](${destination})`)], 'paragraph', path)
return attempt.ok ? attempt.value.line : undefined
}
// A carried node joins no run, so every pass carries at least one more node.
// A carried node joins no run and the opening link turns directive once, so every pass carries at least one more node.
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<EmittedLine> {
const carried = new Set<number>()
let openingLinkAsDirective = false
for (;;) {
const emission = lineSegments(nodes, container, path, carried)
const emission = lineSegments(nodes, container, path, carried, openingLinkAsDirective)
if (!emission.ok) return emission
if (emission.value.carry !== undefined) {
carryRange(carried, emission.value.carry)
@@ -66,8 +77,9 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con
}
const attempt = attemptLine(emission.value.segments, container, path)
if (!attempt.ok) return attempt
if (attempt.value.carry === undefined) return success({ line: attempt.value.line, segments: emission.value.segments })
carryRange(carried, attempt.value.carry)
if (attempt.value.line !== undefined) return success({ line: attempt.value.line, openingLinkAsDirective, segments: emission.value.segments })
if (attempt.value.carry !== undefined) carryRange(carried, attempt.value.carry)
else openingLinkAsDirective = true
}
}
@@ -75,8 +87,15 @@ function carryRange(carried: Set<number>, range: NodeRange): void {
for (let index = range.first; index <= range.last; index += 1) carried.add(index)
}
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, carried: ReadonlySet<number>): Result<Emission> {
const emission = emitRun(nodes, 0, 0, { atBlockEnd: true, bracketed: false, carried, path, spansLines: container === 'paragraph' })
function lineSegments(
nodes: readonly AdfNode[],
container: LineContainer,
path: ConvertErrorPath,
carried: ReadonlySet<number>,
openingLinkAsDirective: boolean,
): Result<Emission> {
const context: InlineContext = { atBlockEnd: true, bracketed: false, carried, openingLinkAsDirective, path, spansLines: container === 'paragraph' }
const emission = emitRun(nodes, 0, 0, context)
if (!emission.ok) return emission
if (emission.value.carry !== undefined) return emission
return success({ segments: carryStrippedWhitespace(emission.value.segments) })
@@ -84,6 +103,7 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path:
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> {
const assembled = assembleInlineLine(segments, container)
if (assembled.openingLinkAsDirective) return success({ openingLinkAsDirective: true })
if (assembled.unspellableRun !== undefined) return success({ carry: assembled.unspellableRun })
for (const [index, single] of assembled.line.split('\n').entries()) {
if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) {
@@ -237,7 +257,13 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number,
if (attributes === undefined) return success({ carry: range })
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path)
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
if (spelling.kind === 'link') return emitLink(nodes, mark, depth, range, context, path)
if (spelling.kind === 'link') {
const href = nodeAttrs(mark)['href']
if (typeof href !== 'string') return success({ carry: range })
const opening = depth === 0 && index === 0 && context.openingLinkAsDirective
const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, isBareLink(nodes, href, depth), context.bracketed)
if (commonMark !== undefined) return emitLink(nodes, href, commonMark, depth, range, context)
}
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
if (!inner.ok) return inner
if (inner.value.carry !== undefined) return inner
@@ -278,20 +304,17 @@ function needsPadding(text: string): boolean {
return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text)
}
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext, path: ConvertErrorPath): Result<Emission> {
const href = nodeAttrs(mark)['href']
const title = nodeAttrs(mark)['title']
if (typeof href !== 'string') return success({ carry: range })
function isBareLink(nodes: readonly AdfNode[], href: string, depth: number): boolean {
const node = nodes[0]
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1
const autolinkHolds = !context.bracketed || (!href.includes('`') && !holdsDirectivePrefix(href) && escapeUnbalanced(href, '[', ']') === href)
if (bare && autolinkHolds && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] })
const target = spellLinkTarget(href, typeof title === 'string' ? title : undefined, path)
if (!target.ok) return target
return nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1
}
function emitLink(nodes: readonly AdfNode[], href: string, commonMark: CommonMarkLink, depth: number, range: NodeRange, context: InlineContext): Result<Emission> {
if (commonMark.form === 'autolink') return success({ segments: [syntax(`<${href}>`)] })
const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true })
if (!inner.ok) return inner
if (inner.value.carry !== undefined) return inner
const spelledTarget: InlineSegment = context.bracketed ? { escaping: 'bracketed-link-target', text: escapeUnbalanced(target.value, '[', ']') } : syntax(target.value)
return success({ segments: [{ escaping: 'none', nodes: range, text: '[' }, ...inner.value.segments, syntax(']('), spelledTarget, syntax(')')] })
const target: InlineSegment = context.bracketed ? { escaping: 'bracketed-link-target', text: escapeUnbalanced(commonMark.target, '[', ']') } : syntax(commonMark.target)
return success({ segments: [{ escaping: 'none', nodes: range, text: '[' }, ...inner.value.segments, syntax(']('), target, syntax(')')] })
}
+2 -3
View File
@@ -17,7 +17,7 @@ export type InlineSegment =
| { emphasis?: undefined; escaping: 'none'; nodes: NodeRange; text: string }
| { emphasis?: undefined; escaping: InlineEscaping; nodes?: undefined; text: string }
export type AssembledLine = { line: string; unspellableRun: NodeRange | undefined }
export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRun: NodeRange | undefined }
type ScanLine = { position: LinePosition; start: number; text: string }
@@ -77,8 +77,7 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
output += scan.charAt(index)
}
if (container === 'paragraph' && opensLinkDefinition(output)) {
const opener = segments[0]?.nodes
if (opener !== undefined) return { line: output, unspellableRun: opener }
if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRun: undefined }
return { line: `\\${output}`, unspellableRun: unspellableRun(segments, output, placements) }
}
return { line: output, unspellableRun: unspellableRun(segments, output, placements) }