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
+1 -2
View File
@@ -28,10 +28,9 @@ function markdownForm(spelling: MarkSpelling): string | undefined {
case 'code':
return '`x`'
case 'directive':
case 'link':
return undefined
case 'emphasis':
return `${spelling.spelling}x${spelling.spelling}`
case 'link':
return '[x](url)'
}
}
+41 -9
View File
@@ -1,15 +1,18 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { DirectiveSpan } from '../directive-syntax.ts'
import type { EmphasisPairing } from '../emphasis-matching.ts'
import type { LineContainer } from '../emit/line-escaping.ts'
import type { LinkDefinition } from '../link-syntax.ts'
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark-grammar.ts'
import { backtickRun, closingBacktickRun } from '../backtick-runs.ts'
import { commonMarkLink } from '../mark-spellings.ts'
import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineDirective } from '../../adf/inline-directives.ts'
import { mergeAdjacentText } from '../../adf/editor-normal.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
import { openingLinkTakesDirective } from '../emit/inline-line.ts'
import { readCarriedInline } from '../opaque-carry.ts'
import { readDirectiveMark } from './directive-marks.ts'
import { readInlineDirective } from '../directive-syntax.ts'
@@ -33,19 +36,29 @@ type Piece =
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number }
type Scan = { definitions: LinkDefinitions; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string }
// `container` is the emitter's line container, `undefined` inside a directive's content slot; `openingSpellableLink` defers a directive link at offset 0 to `assemble`.
type Scan = {
container: LineContainer | undefined
definitions: LinkDefinitions
openingSpellableLink: boolean
path: ConvertErrorPath
pending: string
pieces: Piece[]
source: string
}
type SlotContent = { carry: boolean; nodes: AdfNode[] }
const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can'
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<InlineContent> {
return parseInline(source, definitions, path, true)
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> {
return parseInline(source, definitions, path, container)
}
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, strip: boolean): Result<InlineContent> {
const scan: Scan = { definitions, path, pending: '', pieces: [], source }
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined): Result<InlineContent> {
const scan: Scan = { container, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source }
let index = 0
while (index < source.length) {
switch (source.charAt(index)) {
@@ -93,7 +106,7 @@ function parseInline(source: string, definitions: LinkDefinitions, path: Convert
index += 1
}
}
flush(scan, strip)
flush(scan, container !== undefined)
return assemble(scan)
}
@@ -158,14 +171,14 @@ function readDirective(scan: Scan, index: number): Result<number> | undefined {
const directive = readInlineDirective(scan.source, index)
if (directive === undefined) return undefined
if (directive.fault !== undefined) return faulted(directive.fault, scan.path)
const piece = directivePiece(scan, directive.value)
const piece = directivePiece(scan, directive.value, index)
if (!piece.ok) return piece
flush(scan, false)
scan.pieces.push(piece.value)
return success(index + directive.value.length)
}
function directivePiece(scan: Scan, span: DirectiveSpan): Result<Piece> {
function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<Piece> {
const carried = readCarriedInline(span)
if (carried !== undefined) {
if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
@@ -183,6 +196,8 @@ function directivePiece(scan: Scan, span: DirectiveSpan): Result<Piece> {
return failure('unsupported-node-shape', `the ${span.name} mark wraps the [content] it marks: this one wraps none`, scan.path)
}
if (slot.value.carry) return failure('unsupported-node-shape', carriedInMark, scan.path)
const refused = mark.value.type === 'link' ? refuseSpellableLink(scan, mark.value, slot.value.nodes, index) : undefined
if (refused !== undefined) return refused
return success({ kind: 'nodes', nodes: applyMark(slot.value.nodes, mark.value) })
}
const node = readInlineDirectiveNode(span.name, span.attributes, slot.value?.nodes, scan.path)
@@ -190,9 +205,21 @@ function directivePiece(scan: Scan, span: DirectiveSpan): Result<Piece> {
return success({ kind: 'nodes', nodes: [node.value] })
}
// spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `assemble` asks the emitter.
function refuseSpellableLink(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[], index: number): Result<Piece> | undefined {
const href = nodeAttrs(mark)['href']
if (typeof href !== 'string') return undefined
const only = nodes[0]
const bare = nodes.length === 1 && only !== undefined && only.type === 'text' && only.text === href && nodeMarks(only).length === 0
if (commonMarkLink(nodeAttrs(mark), href, bare, scan.container === undefined) === undefined) return undefined
if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path)
scan.openingSpellableLink = true
return undefined
}
function slotContent(scan: Scan, content: string | undefined): Result<SlotContent | undefined> {
if (content === undefined) return success(undefined)
const parsed = parseInline(content, scan.definitions, scan.path, false)
const parsed = parseInline(content, scan.definitions, scan.path, undefined)
if (!parsed.ok) return parsed
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
return success(parsed.value)
@@ -214,6 +241,11 @@ function assemble(scan: Scan): Result<InlineContent> {
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
const nodes = resolveNodes(scan.pieces, scan.path)
if (!nodes.ok) return nodes
if (scan.openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes.value, scan.path)
if (!takesDirective.ok) return takesDirective
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, scan.path)
}
return success({ carry: holdsCarry(scan.pieces), nodes: nodes.value })
}
+21 -3
View File
@@ -287,8 +287,8 @@ test('names the directive form a node CommonMark spells refuses', () => {
// The spelling the emitter refuses gives the emitter's own error, never a second name for it.
test('gives back the refusal the CommonMark spelling itself raises', () => {
const destination = '!adf:blockquote\n[t](https://example.com/a\\b)\n!adf:/blockquote\n'
assert.equal(content(markdownToAdf(destination)), 'unspellable-link: no canonical escape spells a backslash in a link destination')
const lineStart = '!adf:blockquote\n` `` `\n!adf:/blockquote\n'
assert.equal(content(markdownToAdf(lineStart)), 'unspellable-line-start: block parsing would claim the emitted line "``` `` ```"')
})
test('names the directive name no node reads back to', () => {
@@ -966,7 +966,25 @@ test('names the mark markdown spells, never a directive', () => {
assert.equal(content(markdownToAdf('!adf:strong[a]\n')), 'unsupported-node-shape: strong is spelled **x**, never as a directive')
assert.equal(content(markdownToAdf('!adf:strike[a]\n')), 'unsupported-node-shape: strike is spelled ~~x~~, never as a directive')
assert.equal(content(markdownToAdf('!adf:code[a]\n')), 'unsupported-node-shape: code is spelled `x`, never as a directive')
assert.equal(content(markdownToAdf('!adf:link[a]{href="/u"}\n')), 'unsupported-node-shape: link is spelled [x](url), never as a directive')
})
// spec/flavour.md, Marks: the directive link is refused wherever the emitter would pick the markdown spelling.
test('refuses the directive link CommonMark could spell, and reads the one it could not', () => {
const refused = 'unsupported-node-shape: link takes the directive form only where CommonMark cannot spell it: this one it can'
assert.equal(content(markdownToAdf('!adf:link[a]{href="/u"}\n')), refused)
assert.equal(content(markdownToAdf('See !adf:link[a]{href="/u"}.\n')), refused)
assert.equal(content(markdownToAdf('!adf:link[https://example.com/]{href="https://example.com/"}\n')), refused)
assert.equal(content(markdownToAdf('# !adf:link[`]: a`]{href="/u"}\n')), refused)
assert.equal(content(markdownToAdf('| !adf:link[`]: a`]{href="/u"} |\n| --- |\n')), refused)
assert.equal(content(markdownToAdf('!adf:underline[!adf:link[a]{href="/u"}]\n')), refused)
assert.deepEqual(path(markdownToAdf('Part.\n\nSee !adf:link[a]{href="/u"}.\n')), ['content', 1])
const titled: AdfNode = { marks: [{ attrs: { title: 't' }, type: 'link' }], text: 'a', type: 'text' }
assert.deepEqual(content(markdownToAdf('!adf:link[a]{title=t}\n')), [{ content: [titled], type: 'paragraph' }])
const opening: AdfNode = { marks: [{ attrs: { href: '/u' }, type: 'link' }, { type: 'code' }], text: ']: a', type: 'text' }
assert.deepEqual(content(markdownToAdf('!adf:link[`]: a`]{href="/u"}\n')), [{ content: [opening], type: 'paragraph' }])
assert.deepEqual(content(markdownToAdf('!adf:heading {level=1 localId=h}\n!adf:link[`]: a`]{href="/u"}\n!adf:/heading\n')), [
{ attrs: { level: 1, localId: 'h' }, content: [opening], type: 'heading' },
])
})
test('names the directive mark left without the content it wraps', () => {
+7 -6
View File
@@ -2,6 +2,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block, DirectiveBlock } from './blocks.ts'
import type { BlockDirectiveNode } from './directive-nodes.ts'
import type { ConvertFault } from '../../result.ts'
import type { LineContainer } from '../emit/line-escaping.ts'
import type { LinkDefinitions } from './inline-content.ts'
import { carryName, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling } from '../emit/adf-to-markdown.ts'
@@ -67,7 +68,7 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro
case 'fault':
return faulted(block.fault, path)
case 'heading':
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path)
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path, 'heading')
case 'html':
return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path)
case 'orderedList':
@@ -119,7 +120,7 @@ function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path
const type = rowIndex === 0 ? 'tableHeader' : 'tableCell'
const row: AdfNode[] = []
for (const [cellIndex, cell] of cells.entries()) {
const paragraph = contentNode({ type: 'paragraph' }, cell, definitions, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
const paragraph = contentNode({ type: 'paragraph' }, cell, definitions, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], 'table-cell')
if (!paragraph.ok) return paragraph
row.push({ content: [paragraph.value], type })
}
@@ -133,7 +134,7 @@ function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: Li
const only = blocks.length === 1 ? blocks[0] : undefined
if (only?.kind === 'fault') return positioned(faulted(only.fault, path), only.position)
if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body: this body is not one`, path)
return positioned(contentNode(node, only.text, definitions, path), only.position)
return positioned(contentNode(node, only.text, definitions, path, 'paragraph'), only.position)
}
function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
@@ -168,14 +169,14 @@ function codeBlockNode(language: string, text: string, path: ConvertErrorPath, d
// spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs.
function paragraphNode(text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
const content = parseInlineContent(text, definitions, path)
const content = parseInlineContent(text, definitions, path, 'paragraph')
if (!content.ok) return content
const image = content.value.image
return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image)
}
function contentNode(node: AdfNode, text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
const content = parseInlineContent(text, definitions, path)
function contentNode(node: AdfNode, text: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> {
const content = parseInlineContent(text, definitions, path, container)
if (!content.ok) return content
if (content.value.image !== undefined) return failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path)
return success(withContent(node, content.value.nodes))