13b - link takes the directive form where CommonMark cannot spell it, unspellable-link leaves the code list #93

Merged
lilleman merged 3 commits from 13b into main 2026-09-16 23:29:27 +02:00
5 changed files with 35 additions and 35 deletions
Showing only changes of commit b792c946c5 - Show all commits
+1 -1
View File
@@ -61,7 +61,7 @@ normalizes to it through the round-trip.
code span, so such a string still closes one. code span, so such a string still closes one.
Where a paragraph opens with what reads as a link reference definition, which resolves before Where a paragraph opens with what reads as a link reference definition, which resolves before
any inline construct binds (a `]` inside a code span or `{attrs}` counts), an opening text `[` any inline construct binds (a `]` inside a code span or `{attrs}` counts), an opening text `[`
escapes and an opening link's nodes ride the carry. escapes and an opening link takes the directive link (Marks).
An emphasis delimiter run in text escapes where CommonMark can open **or** close with it, so An emphasis delimiter run in text escapes where CommonMark can open **or** close with it, so
`*not emphasis*` is `\*not emphasis\*` — no delimiter the emitter did not write reaches the `*not emphasis*` is `\*not emphasis\*` — no delimiter the emitter did not write reaches the
matching below, which is what lets the emitter decide its own pairings. matching below, which is what lets the emitter decide its own pairings.
+10 -14
View File
@@ -3,7 +3,7 @@ import type { InlineDirective } from '../../adf/inline-directives.ts'
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts' import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts'
import { carriedInline } from '../opaque-carry.ts' import { carriedInline } from '../opaque-carry.ts'
import { claimsLine, holdsNullCharacter } from '../commonmark-grammar.ts' import { claimsLine, holdsNullCharacter } from '../commonmark-grammar.ts'
import { commonMarkLink, markSpelling, spellMarkAttributes, type CommonMarkLink } from '../mark-spellings.ts' import { commonMarkLink, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { escapeUnbalanced, spellDestination } from '../link-syntax.ts' import { escapeUnbalanced, spellDestination } from '../link-syntax.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineDirective } from '../../adf/inline-directives.ts' import { inlineDirective } from '../../adf/inline-directives.ts'
@@ -257,13 +257,8 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number,
if (attributes === undefined) return success({ carry: range }) if (attributes === undefined) return success({ carry: range })
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path) 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 === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
if (spelling.kind === 'link') { const link = spelling.kind === 'link' ? emitLink(nodes, mark, depth, range, context) : undefined
const href = nodeAttrs(mark)['href'] if (link !== undefined) return link
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 }) const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
if (!inner.ok) return inner if (!inner.ok) return inner
if (inner.value.carry !== undefined) return inner if (inner.value.carry !== undefined) return inner
@@ -304,12 +299,13 @@ function needsPadding(text: string): boolean {
return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text) return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text)
} }
function isBareLink(nodes: readonly AdfNode[], href: string, depth: number): boolean { // `undefined` where the link takes the directive form the caller spells.
const node = nodes[0] function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result<Emission> | undefined {
return nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1 const href = nodeAttrs(mark)['href']
} if (typeof href !== 'string') return success({ carry: range })
const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective
function emitLink(nodes: readonly AdfNode[], href: string, commonMark: CommonMarkLink, depth: number, range: NodeRange, context: InlineContext): Result<Emission> { const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes, depth + 1, context.bracketed)
if (commonMark === undefined) return undefined
if (commonMark.form === 'autolink') return success({ segments: [syntax(`<${href}>`)] }) if (commonMark.form === 'autolink') return success({ segments: [syntax(`<${href}>`)] })
const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true }) const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true })
if (!inner.ok) return inner if (!inner.ok) return inner
+10 -5
View File
@@ -1,4 +1,4 @@
import type { AdfAttributes, AdfMark } from '../adf/document.ts' import type { AdfAttributes, AdfMark, AdfNode } from '../adf/document.ts'
import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { MarkType } from '../adf/mark-attributes.ts' import type { MarkType } from '../adf/mark-attributes.ts'
import { escapeUnbalanced, spellLinkTarget } from './link-syntax.ts' import { escapeUnbalanced, spellLinkTarget } from './link-syntax.ts'
@@ -6,7 +6,7 @@ import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './direct
import { holdsEntityReference } from './entity-references.ts' import { holdsEntityReference } from './entity-references.ts'
import { isAutolink } from './commonmark-grammar.ts' import { isAutolink } from './commonmark-grammar.ts'
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts' import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
import { nodeAttrs } from '../adf/document.ts' import { nodeAttrs, nodeMarks } from '../adf/document.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string } type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string }
@@ -35,16 +35,21 @@ export function markSpelling(type: string): MarkSpelling | undefined {
return { attributes, kind: spelling.kind } return { attributes, kind: spelling.kind }
} }
// spec/flavour.md, Marks. `bare`: one text node reading exactly `href`, the link innermost. // spec/flavour.md, Marks. `marksInside` counts the marks the link's nodes carry within it: the emitter's depth, the parser's none.
export function commonMarkLink(attrs: AdfAttributes, href: string, bare: boolean, bracketed: boolean): CommonMarkLink | undefined { export function commonMarkLink(attrs: AdfAttributes, href: string, nodes: readonly AdfNode[], marksInside: number, bracketed: boolean): CommonMarkLink | undefined {
if (Object.keys(attrs).some((key) => key !== 'href' && key !== 'title')) return undefined if (Object.keys(attrs).some((key) => key !== 'href' && key !== 'title')) return undefined
const title = attrs['title'] const title = attrs['title']
const autolinkHolds = !bracketed || (!href.includes('`') && !holdsDirectivePrefix(href) && escapeUnbalanced(href, '[', ']') === href) const autolinkHolds = !bracketed || (!href.includes('`') && !holdsDirectivePrefix(href) && escapeUnbalanced(href, '[', ']') === href)
if (bare && autolinkHolds && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return { form: 'autolink' } if (isBareLink(nodes, href, marksInside) && autolinkHolds && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return { form: 'autolink' }
const target = spellLinkTarget(href, typeof title === 'string' ? title : undefined) const target = spellLinkTarget(href, typeof title === 'string' ? title : undefined)
return target === undefined ? undefined : { form: 'inline', target } return target === undefined ? undefined : { form: 'inline', target }
} }
function isBareLink(nodes: readonly AdfNode[], href: string, marksInside: number): boolean {
const node = nodes[0]
return nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === marksInside
}
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined { export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined {
const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, []) const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, [])
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
+13 -14
View File
@@ -51,7 +51,7 @@ type SlotContent = { carry: boolean; nodes: AdfNode[] }
const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included' 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 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' const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> { export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> {
return parseInline(source, definitions, path, container) return parseInline(source, definitions, path, container)
@@ -190,28 +190,27 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
const slot = slotContent(scan, span.content) const slot = slotContent(scan, span.content)
if (!slot.ok) return slot if (!slot.ok) return slot
const mark = readDirectiveMark(span.name, span.attributes, scan.path) const mark = readDirectiveMark(span.name, span.attributes, scan.path)
if (mark !== undefined) { if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark
if (!mark.ok) return mark
if (slot.value === undefined || slot.value.nodes.length === 0) {
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) const node = readInlineDirectiveNode(span.name, span.attributes, slot.value?.nodes, scan.path)
if (!node.ok) return node if (!node.ok) return node
return success({ kind: 'nodes', nodes: [node.value] }) return success({ kind: 'nodes', nodes: [node.value] })
} }
function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result<Piece> {
if (slot === undefined || slot.nodes.length === 0) {
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path)
}
if (slot.carry) return failure('unsupported-node-shape', carriedInMark, scan.path)
const refused = mark.type === 'link' ? refuseSpellableLink(scan, mark, slot.nodes, index) : undefined
if (refused !== undefined) return refused
return success({ kind: 'nodes', nodes: applyMark(slot.nodes, mark) })
}
// 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. // 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 { function refuseSpellableLink(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[], index: number): Result<Piece> | undefined {
const href = nodeAttrs(mark)['href'] const href = nodeAttrs(mark)['href']
if (typeof href !== 'string') return undefined if (typeof href !== 'string') return undefined
const only = nodes[0] if (commonMarkLink(nodeAttrs(mark), href, nodes, 0, scan.container === undefined) === undefined) return undefined
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) if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path)
scan.openingSpellableLink = true scan.openingSpellableLink = true
return undefined return undefined
+1 -1
View File
@@ -970,7 +970,7 @@ test('names the mark markdown spells, never a directive', () => {
// spec/flavour.md, Marks: the directive link is refused wherever the emitter would pick the markdown spelling. // 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', () => { 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' const refused = 'unsupported-node-shape: link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
assert.equal(content(markdownToAdf('!adf:link[a]{href="/u"}\n')), refused) 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('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[https://example.com/]{href="https://example.com/"}\n')), refused)