Read the carries and the combinations back, and land markdownToAdf
CI / gate (push) Successful in 8s

This commit is contained in:
2026-09-03 07:01:27 +02:00
parent f93dda8697
commit d7850c75f4
19 changed files with 213 additions and 56 deletions
+49 -2
View File
@@ -1,14 +1,19 @@
import type { AdfNode } from '../adf/document.ts'
import type { ConvertFault } from '../result.ts'
import type { DirectiveSpan, Read } from './directive-syntax.ts'
import type { JsonSpelling } from '../canonical-json.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
import { isAdfNode } from '../adf/document.ts'
import { isJsonValue } from '../json-value.ts'
import { fencedCodeBlock } from './backtick-runs.ts'
import { largestNesting } from '../nesting.ts'
import { malformedDirective, spellAttributes, spellStringAttribute } from './directive-syntax.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
import { spellAttributes, spellStringAttribute } from './directive-syntax.ts'
export const carryName = 'adf'
const jsonAttribute = 'json'
export function carriedBlock(node: AdfNode, path: ConvertErrorPath): Result<string> {
const json = carriedJson(node, 'two-space', path)
if (!json.ok) return json
@@ -18,7 +23,21 @@ export function carriedBlock(node: AdfNode, path: ConvertErrorPath): Result<stri
export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> {
const json = carriedJson(node, 'compact', path)
if (!json.ok) return json
return success(`:${carryName}${spellAttributes([['json', spellStringAttribute(json.value)]])}`)
return success(`:${carryName}${spellAttributes([[jsonAttribute, spellStringAttribute(json.value)]])}`)
}
export function readCarriedBlock(body: string): Read<AdfNode> {
return readCarriedJson(body, 'two-space')
}
export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefined {
if (span.name !== carryName) return undefined
if (span.content !== undefined) return { fault: unsupported(`${carryName} takes no content`) }
const spelled = span.attributes.get(jsonAttribute)
if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupported(`${carryName} holds one ${jsonAttribute} attribute alone`) }
const spelling = spellStringAttribute(spelled.decoded)
if (spelling !== spelled.spelling) return { fault: unsupported(`${carryName} spells its ${jsonAttribute} attribute as ${jsonAttribute}=${spelling}`) }
return readCarriedJson(spelled.decoded, 'compact')
}
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath): Result<string> {
@@ -27,3 +46,31 @@ function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPa
}
return success(serializeCanonicalJson(node, spelling))
}
function readCarriedJson(raw: string, spelling: JsonSpelling): Read<AdfNode> {
const parsed = parseJsonText(raw)
if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') }
const { value } = parsed
if (!isJsonValue(value)) {
return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${largestNesting} levels the parser carries` } }
}
if (serializeCanonicalJson(value, spelling) !== raw) {
const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted'
return { fault: unsupported(`the opaque carry spells its node's JSON canonically: ${shape}`) }
}
if (!isAdfNode(value)) return { fault: unsupported("the opaque carry holds one ADF node's JSON") }
return { value }
}
function parseJsonText(raw: string): { value: unknown } | undefined {
try {
const value: unknown = JSON.parse(raw)
return { value }
} catch {
return undefined
}
}
function unsupported(message: string): ConvertFault {
return { code: 'unsupported-node-shape', message }
}
+52 -22
View File
@@ -9,12 +9,13 @@ import { failure, faulted, success, type ConvertErrorPath, type Result } from '.
import { inlineDirective } from '../../adf/inline-directives.ts'
import { mergeAdjacentText } from '../../adf/editor-normal.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
import { readCarriedInline } from '../opaque-carry.ts'
import { readDirectiveMark } from './directive-marks.ts'
import { readInlineDirective } from '../directive-syntax.ts'
import { readInlineDirectiveNode } from './directive-nodes.ts'
import { readTextDirective } from '../text-directive.ts'
export type InlineContent = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: AdfNode[] }
export type InlineContent = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: AdfNode[] }
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
@@ -24,6 +25,7 @@ type Pairing = EmphasisPairing<Run>
type Piece =
| Bracket
| { kind: 'carry'; node: AdfNode }
| { alt: string; kind: 'image'; node: AdfNode }
| { kind: 'nodes'; nodes: AdfNode[] }
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
@@ -32,6 +34,9 @@ type Run = { canClose: boolean; canOpen: boolean; character: string; index: numb
type Scan = { definitions: LinkDefinitions; 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'
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<InlineContent> {
@@ -152,38 +157,44 @@ function readDirective(scan: Scan, index: number): Result<number> {
return success(index + 1)
}
if (directive.fault !== undefined) return faulted(directive.fault, scan.path)
const nodes = directiveNodes(scan, directive.value)
if (!nodes.ok) return nodes
const piece = directivePiece(scan, directive.value)
if (!piece.ok) return piece
flush(scan, false)
scan.pieces.push({ kind: 'nodes', nodes: nodes.value })
scan.pieces.push(piece.value)
return success(index + directive.value.length)
}
function directiveNodes(scan: Scan, span: DirectiveSpan): Result<AdfNode[]> {
function directivePiece(scan: Scan, span: DirectiveSpan): Result<Piece> {
const carried = readCarriedInline(span)
if (carried !== undefined) {
if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
return success({ kind: 'carry', node: carried.value })
}
const text = readTextDirective(span)
if (text?.fault !== undefined) return faulted(text.fault, scan.path)
if (text !== undefined) return success([{ text: text.value, type: 'text' }])
if (text !== undefined) return success({ kind: 'nodes', nodes: [{ text: text.value, type: 'text' }] })
const slot = slotNodes(scan, span.content)
if (!slot.ok) return slot
const mark = readDirectiveMark(span.name, span.attributes, scan.path)
if (mark !== undefined) {
if (!mark.ok) return mark
if (slot.value === undefined || slot.value.length === 0) {
if (slot.value === undefined || slot.value.nodes.length === 0) {
return failure('unsupported-node-shape', `the ${span.name} mark wraps the [content] it marks`, scan.path)
}
return success(applyMark(slot.value, mark.value))
if (slot.value.carry) return failure('unsupported-node-shape', carriedInMark, scan.path)
return success({ kind: 'nodes', nodes: applyMark(slot.value.nodes, mark.value) })
}
const node = readInlineDirectiveNode(span.name, span.attributes, slot.value, scan.path)
const node = readInlineDirectiveNode(span.name, span.attributes, slot.value?.nodes, scan.path)
if (!node.ok) return node
return success([node.value])
return success({ kind: 'nodes', nodes: [node.value] })
}
function slotNodes(scan: Scan, content: string | undefined): Result<AdfNode[] | undefined> {
function slotNodes(scan: Scan, content: string | undefined): Result<SlotContent | undefined> {
if (content === undefined) return success(undefined)
const parsed = parseInline(content, scan.definitions, scan.path, false)
if (!parsed.ok) return parsed
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
return success(parsed.value.nodes)
return success(parsed.value)
}
function flush(scan: Scan, strip: boolean): void {
@@ -200,7 +211,13 @@ function assemble(scan: Scan): Result<InlineContent> {
const only = scan.pieces[0]
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
return success({ nodes: resolveNodes(scan.pieces) })
const nodes = resolveNodes(scan.pieces, scan.path)
if (!nodes.ok) return nodes
return success({ carry: holdsCarry(scan.pieces), nodes: nodes.value })
}
function holdsCarry(pieces: readonly Piece[]): boolean {
return pieces.some((piece) => piece.kind === 'carry')
}
function holdsImage(pieces: readonly Piece[]): boolean {
@@ -286,7 +303,10 @@ function resolveTarget(scan: Scan, bracket: Bracket, index: number): { definitio
// `false` where the link text is empty: the mark has no node to ride, so the brackets stay text.
function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<boolean> {
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
const nodes = resolveNodes(inner)
if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path)
const resolved = resolveNodes(inner, scan.path)
if (!resolved.ok) return resolved
const nodes = resolved.value
if (nodes.length === 0) return success(false)
const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title }
scan.pieces.length = at
@@ -298,15 +318,19 @@ function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition:
function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<null> {
if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path)
const alt = imageAlt(inner)
const resolved = imageAlt(inner, scan.path)
if (!resolved.ok) return resolved
const alt = resolved.value
const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination }
scan.pieces.length = at
scan.pieces.push({ alt, kind: 'image', node: { attrs: { layout: 'center' }, content: [{ attrs, type: 'media' }], type: 'mediaSingle' } })
return success(null)
}
function imageAlt(inner: readonly Piece[]): string {
return resolveNodes(inner).map(altText).join('')
function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<string> {
const nodes = resolveNodes(inner, path)
if (!nodes.ok) return nodes
return success(nodes.value.map(altText).join(''))
}
// spec/flavour.md, The CommonMark image: the description's plain text, the content slot included.
@@ -317,18 +341,20 @@ function altText(node: AdfNode): string {
return typeof spelled === 'string' ? spelled : (node.text ?? '')
}
function resolveNodes(pieces: readonly Piece[]): AdfNode[] {
function resolveNodes(pieces: readonly Piece[], path: ConvertErrorPath): Result<AdfNode[]> {
const nodes = pieces.map(pieceNodes)
const runs = delimiterRuns(pieces)
const pairings = matchEmphasis(runs)
writeUnpaired(nodes, runs, pairings)
markPairings(nodes, pairings)
return mergeAdjacentText(nodes.flat())
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, path)
return success(mergeAdjacentText(nodes.flat()))
}
// Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first.
function pieceNodes(piece: Piece): AdfNode[] {
switch (piece.kind) {
case 'carry':
return [piece.node]
case 'image':
return piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }]
case 'nodes':
@@ -364,11 +390,15 @@ function writeUnpaired(nodes: AdfNode[][], runs: readonly Run[], pairings: reado
}
// Innermost pairing first, so prepending leaves the marks array outermost first (spec/flavour.md, Marks).
function markPairings(nodes: AdfNode[][], pairings: readonly Pairing[]): void {
function markPairings(pieces: readonly Piece[], nodes: AdfNode[][], pairings: readonly Pairing[]): boolean {
for (const pairing of pairings) {
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) nodes[index] = applyMark(nodes[index] ?? [], mark)
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) {
if (pieces[index]?.kind === 'carry') return false
nodes[index] = applyMark(nodes[index] ?? [], mark)
}
}
return true
}
function markType(character: string, used: number): string {
+69 -1
View File
@@ -266,10 +266,78 @@ test('names the reserved carry name a block directive spells', () => {
const reserved = 'malformed-directive: the name adf is reserved for the opaque carry, whose block form is the fence'
assert.equal(content(markdownToAdf('::adf\n')), reserved)
assert.equal(content(markdownToAdf(':::adf\nx\n:::\n')), reserved)
assert.equal(content(markdownToAdf('```adf\nx\n```\n')), 'malformed-directive: the info string adf is reserved for the opaque carry')
assert.deepEqual(content(markdownToAdf('```adfx\nx\n```\n')), [{ attrs: { language: 'adfx' }, content: [text('x')], type: 'codeBlock' }])
})
const carried = ':adf{json="{\\"type\\":\\"placeholder\\"}"}'
test('reads the adf fence back to the node its JSON holds', () => {
assert.deepEqual(content(markdownToAdf('```adf\n{\n "attrs": {\n "url": "https://example.com/x"\n },\n "type": "blockCard"\n}\n```\n')), [
{ attrs: { url: 'https://example.com/x' }, type: 'blockCard' },
])
})
test('reads the inline carry back to the node its json attribute holds', () => {
assert.deepEqual(content(markdownToAdf(`a ${carried} b\n`)), [
{ content: [text('a '), { type: 'placeholder' }, text(' b')], type: 'paragraph' },
])
})
test('names the invalid JSON no opaque carry holds', () => {
const invalid = 'malformed-directive: the opaque carry holds invalid JSON'
assert.equal(content(markdownToAdf('```adf\n{"type":\n```\n')), invalid)
assert.equal(content(markdownToAdf('```adf\n```\n')), invalid)
assert.equal(content(markdownToAdf(':adf{json="{"}\n')), invalid)
assert.equal(content(markdownToAdf(':adf{json=abc}\n')), invalid)
})
test('names the canonical spelling a carried JSON reads alone', () => {
const canonically = "unsupported-node-shape: the opaque carry spells its node's JSON canonically: "
assert.equal(content(markdownToAdf('```adf\n{"type":"blockCard"}\n```\n')), `${canonically}two-space indent, keys sorted`)
assert.equal(content(markdownToAdf(':adf{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted`)
assert.equal(content(markdownToAdf(':adf{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted`)
})
test('names the node JSON an opaque carry restores alone', () => {
const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON"
assert.equal(content(markdownToAdf('```adf\n[]\n```\n')), node)
assert.equal(content(markdownToAdf(':adf{json=null}\n')), node)
assert.equal(content(markdownToAdf(':adf{json="{\\"kind\\":\\"x\\"}"}\n')), node)
})
test('names the shape the inline carry reads alone', () => {
assert.equal(content(markdownToAdf(':adf[x]{json="{}"}\n')), 'unsupported-node-shape: adf takes no content')
assert.equal(content(markdownToAdf(':adf{}\n')), 'unsupported-node-shape: adf holds one json attribute alone')
assert.equal(content(markdownToAdf(':adf{json="{}" localId=x}\n')), 'unsupported-node-shape: adf holds one json attribute alone')
assert.equal(content(markdownToAdf(':adf{json="null"}\n')), 'unsupported-node-shape: adf spells its json attribute as json=null')
})
test('holds a carried JSON value to the nesting the parser carries', () => {
const deep = `:adf{json="${'['.repeat(largestNesting + 2)}${']'.repeat(largestNesting + 2)}"}\n`
assert.equal(content(markdownToAdf(deep)), `unsupported-nesting-depth: a carried node's JSON nests deeper than the ${largestNesting} levels the parser carries`)
})
test('names the mark spelling no opaque carry sits inside', () => {
const named = 'unsupported-node-shape: no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
assert.equal(content(markdownToAdf(`_a ${carried} b_\n`)), named)
assert.equal(content(markdownToAdf(`**${carried}**\n`)), named)
assert.equal(content(markdownToAdf(`~~a ${carried}~~\n`)), named)
assert.equal(content(markdownToAdf(`[a ${carried} b](https://example.com/x)\n`)), named)
assert.equal(content(markdownToAdf(`:underline[${carried}]\n`)), named)
assert.equal(content(markdownToAdf(`:textColor[a ${carried}]{color="#ae2e24"}\n`)), named)
assert.equal(content(markdownToAdf(`![_a ${carried}_](https://example.com/i)\n`)), named)
})
test('keeps the carry a mark spelling does not wrap', () => {
assert.deepEqual(content(markdownToAdf(`[a ${carried} b]\n`)), [
{ content: [text('[a '), { type: 'placeholder' }, text(' b]')], type: 'paragraph' },
])
assert.deepEqual(content(markdownToAdf(`**a**${carried}**b**\n`)), [
{ content: [marked('a', strong), { type: 'placeholder' }, marked('b', strong)], type: 'paragraph' },
])
assert.deepEqual(content(markdownToAdf(`![a ${carried} b](https://example.com/i)\n`)), [image('https://example.com/i', 'a b')])
})
test('reads each attribute value as the type its section assigns', () => {
assert.deepEqual(content(markdownToAdf('::media {height=10 id=a-1 type=file url="/x y" width="20.5"}\n')), [
{ attrs: { height: 10, id: 'a-1', type: 'file', url: '/x y', width: 20.5 }, type: 'media' },
+6 -2
View File
@@ -2,7 +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 { LinkDefinitions } from './inline-content.ts'
import { carryName } from '../opaque-carry.ts'
import { carryName, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling } from '../emit/adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { languageSlot } from '../code-language.ts'
@@ -135,7 +135,11 @@ function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDef
}
function codeBlockNode(language: string, text: string, path: ConvertErrorPath): Result<AdfNode> {
if (language === carryName) return failure('malformed-directive', `the info string ${carryName} is reserved for the opaque carry`, path)
if (language === carryName) {
const carried = readCarriedBlock(text)
if (carried.fault !== undefined) return faulted(carried.fault, path)
return success(carried.value)
}
const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' }
return success(text === '' ? node : { ...node, content: [{ text, type: 'text' }] })
}