13b - link takes the directive form where CommonMark cannot spell it, unspellable-link leaves the code list
This commit is contained in:
@@ -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)'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user