13b - link takes the directive form where CommonMark cannot spell it, unspellable-link leaves the code list
This commit is contained in:
@@ -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&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&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&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\\&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 & 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')
|
||||
|
||||
@@ -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('`)], 'paragraph', path)
|
||||
const attempt = attemptLine([syntax('`)], '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(')')] })
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user