Settle one failure policy for the trial spellings, and lift the attribute vocabulary above the markdown layer
CI / gate (push) Successful in 5s

This commit is contained in:
2026-08-26 09:19:43 +02:00
parent efc267db2b
commit b196132dae
10 changed files with 93 additions and 70 deletions
+2
View File
@@ -4,6 +4,8 @@ export type AdfAttributes = { [key: string]: JsonValue }
export type AttributeKind = 'boolean' | 'json' | 'number' | 'string'
export type AttributeVocabulary = Readonly<Record<string, AttributeKind>>
export type AdfMark = {
attrs?: AdfAttributes
type: string
+11 -7
View File
@@ -2,9 +2,9 @@ import type { AdfDocument, AdfNode } from './adf-document.ts'
import type { BlockDirective } from './block-directives.ts'
import type { JsonValue } from './json-value.ts'
import { blockDirective, spellDirectiveHeader } from './block-directives.ts'
import { emitImage } from './markdown-image.ts'
import { tryImage } from './markdown-image.ts'
import { emitInlineLine } from './markdown-inline.ts'
import { emitPipeTable } from './markdown-pipe-table.ts'
import { tryPipeTable } from './markdown-pipe-table.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts'
import { isAdfDocument } from './adf-document.ts'
@@ -98,7 +98,11 @@ function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result
function commonMarkLine(text: Result<string>): Result<EmittedBlock> {
if (!text.ok) return text
return success({ fenceColons: 0, spelling: 'commonmark', text: text.value })
return success(commonMarkText(text.value))
}
function commonMarkText(text: string): EmittedBlock {
return { fenceColons: 0, spelling: 'commonmark', text }
}
function commonMarkContainer(body: Result<EmittedBody>): Result<EmittedBlock> {
@@ -131,15 +135,15 @@ function emitInlineBody(content: readonly AdfNode[], path: ConvertErrorPath): Re
}
function emitMediaSingle(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const image = emitImage(node, path)
const image = tryImage(node, path)
if (image === undefined) return emitDirectiveBlock(node, directive, path, depth)
return commonMarkLine(image)
return success(commonMarkText(image))
}
function emitTable(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const pipe = emitPipeTable(node, path)
const pipe = tryPipeTable(node, path)
if (pipe === undefined) return emitDirectiveBlock(node, directive, path, depth)
return commonMarkLine(pipe)
return success(commonMarkText(pipe))
}
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBody> {
+5 -6
View File
@@ -1,5 +1,4 @@
import type { AdfMark, AdfNode } from './adf-document.ts'
import type { AttributeVocabulary } from './directive-attributes.ts'
import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts'
import type { JsonValue } from './json-value.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { attributeFailure, isBareToken, spellAttributes, spellJsonAttribute, vocabularyPairs } from './directive-attributes.ts'
@@ -84,11 +83,11 @@ export function blockDirective(type: string): BlockDirective | undefined {
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<string> {
const argument = spellArgument(node, directive, path)
if (!argument.ok) return argument
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.argument)
if (!Array.isArray(pairs)) return attributeFailure(node.type, pairs, path)
const spelled = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.argument)
if (spelled.fault !== undefined) return attributeFailure(node.type, spelled.fault, path)
const marks = node.marks ?? []
if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))])
const attributes = spellAttributes(pairs)
if (marks.length > 0) spelled.pairs.push(['marks', spellJsonAttribute(markValues(marks))])
const attributes = spellAttributes(spelled.pairs)
return success(`${node.type}${argument.value}${attributes === '' ? '' : ` ${attributes}`}`)
}
+6 -6
View File
@@ -1,11 +1,11 @@
import type { AdfAttributes, AttributeKind } from './adf-document.ts'
import type { AdfAttributes, AttributeKind, AttributeVocabulary } from './adf-document.ts'
import type { JsonValue } from './json-value.ts'
import { failure, type ConvertErrorPath, type Result } from './result.ts'
import { serializeCanonicalJson } from './canonical-json.ts'
export type AttributeFault = { key: string; kind: AttributeKind | undefined }
export type AttributeVocabulary = Readonly<Record<string, AttributeKind>>
export type SpelledPairs = { fault: AttributeFault; pairs?: undefined } | { fault?: undefined; pairs: [string, string][] }
const bareToken = /^[A-Za-z0-9_-]+$/
@@ -18,17 +18,17 @@ export function attributeFailure<T>(type: string, fault: AttributeFault, path: C
return failure('unsupported-node-shape', `the ${type} attribute ${fault.key} holds no ${fault.kind}`, path)
}
export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, slot: string | undefined): AttributeFault | [string, string][] {
export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, slot: string | undefined): SpelledPairs {
const pairs: [string, string][] = []
for (const [key, value] of Object.entries(attrs)) {
if (key === slot) continue
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
if (kind === undefined) return { key, kind: undefined }
if (kind === undefined) return { fault: { key, kind: undefined } }
const spelled = spellAttributeValue(value, kind)
if (spelled === undefined) return { key, kind }
if (spelled === undefined) return { fault: { key, kind } }
pairs.push([key, spelled])
}
return pairs
return { pairs }
}
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
+13 -11
View File
@@ -1,5 +1,5 @@
import type { AdfMark, AdfNode } from './adf-document.ts'
import type { AttributeVocabulary } from './directive-attributes.ts'
import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts'
import type { AttributeFault } from './directive-attributes.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { attributeFailure, spellAttributes, vocabularyPairs } from './directive-attributes.ts'
@@ -45,16 +45,18 @@ export function markDirective(type: string): AttributeVocabulary | undefined {
}
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result<string> {
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot)
if (!Array.isArray(pairs)) return attributeFailure(node.type, pairs, path)
return success(spellAttributes(pairs))
const spelled = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot)
if (spelled.fault !== undefined) return attributeFailure(node.type, spelled.fault, path)
return success(spellAttributes(spelled.pairs))
}
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary, path: ConvertErrorPath): Result<string> {
const pairs = vocabularyPairs(mark.attrs ?? {}, vocabulary, undefined)
if (!Array.isArray(pairs)) {
if (pairs.kind === undefined) return failure('unspellable-mark', `the ${mark.type} spelling holds no ${pairs.key} attribute`, path)
return failure('unspellable-mark', `the ${mark.type} attribute ${pairs.key} holds no ${pairs.kind}`, path)
}
return success(spellAttributes(pairs))
const spelled = vocabularyPairs(mark.attrs ?? {}, vocabulary, undefined)
if (spelled.fault !== undefined) return markFailure(mark.type, spelled.fault, path)
return success(spellAttributes(spelled.pairs))
}
function markFailure<T>(type: string, fault: AttributeFault, path: ConvertErrorPath): Result<T> {
if (fault.kind === undefined) return failure('unspellable-mark', `the ${type} spelling holds no ${fault.key} attribute`, path)
return failure('unspellable-mark', `the ${type} attribute ${fault.key} holds no ${fault.kind}`, path)
}
+4 -5
View File
@@ -1,16 +1,15 @@
import type { AdfNode } from './adf-document.ts'
import type { ConvertErrorPath, Result } from './result.ts'
import { emitImageLine } from './markdown-inline.ts'
import type { ConvertErrorPath } from './result.ts'
import { serializeCanonicalJson } from './canonical-json.ts'
import { tryImageLine } from './markdown-inline.ts'
const centeredMediaSingle = '{"layout":"center"}'
const imageAttributes = ['alt', 'type', 'url']
export function emitImage(node: AdfNode, path: ConvertErrorPath): Result<string> | undefined {
export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefined {
const image = imageShape(node)
if (image === undefined) return undefined
const line = emitImageLine(image.alt, image.url, [...path, 'content', 0])
return line.ok ? line : undefined
return tryImageLine(image.alt, image.url, [...path, 'content', 0])
}
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
+18 -16
View File
@@ -7,7 +7,7 @@ import { claimsLine, holdsControlCharacter, holdsEntityReference, holdsNullChara
import { failure, success, type ConvertErrorPath, type Result } from './result.ts'
import { longestBacktickRun } from './backtick-runs.ts'
import { serializeCanonicalJson } from './canonical-json.ts'
import { spellStringAttribute } from './directive-attributes.ts'
import { spellAttributes, spellStringAttribute } from './directive-attributes.ts'
type Brackets = 'directive' | 'link' | 'none'
@@ -29,23 +29,21 @@ export function emitInlineLine(nodes: readonly AdfNode[], container: LineContain
return finishLine(segments.value, container, path)
}
// undefined where the cell holds a pipe no backslash reaches, leaving the table its directive form.
export function emitPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<string> | undefined {
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
const segments = lineSegments(nodes, 'table-cell', path)
if (!segments.ok) return segments
if (!segments.ok) return undefined
if (segments.value.some((segment) => segment.escaping === 'none' && segment.text.includes('|'))) return undefined
return finishLine(segments.value, 'table-cell', path)
const line = finishLine(segments.value, 'table-cell', path)
return line.ok ? line.value : undefined
}
export function emitImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): Result<string> {
if (alt !== undefined && /^[ \t]|[ \t]$|[\n\r]/.test(alt)) {
return failure('unspellable-whitespace', 'a media alt holds whitespace no image description spells', path)
}
if (alt !== undefined && holdsNullCharacter(alt)) return failure('unspellable-character', 'a media alt holds a null character CommonMark replaces', path)
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 destination
if (!destination.ok) return undefined
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
return finishLine([syntax('!['), ...description, syntax(`](${destination.value})`)], 'paragraph', path)
const line = finishLine([syntax('!['), ...description, syntax(`](${destination.value})`)], 'paragraph', path)
return line.ok ? line.value : undefined
}
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<InlineSegment[]> {
@@ -71,7 +69,7 @@ function finishLine(segments: readonly InlineSegment[], container: LineContainer
return success(line)
}
// spec/flavour.md, Inline nodes: whitespace CommonMark strips at a line edge rides the reserved text directive.
// spec/flavour.md, Inline nodes: whitespace CommonMark strips.
function carryStrippedWhitespace(segments: readonly InlineSegment[]): InlineSegment[] {
const carried: InlineSegment[] = []
for (const [index, segment] of segments.entries()) {
@@ -98,7 +96,11 @@ function carryEdges(segment: InlineSegment, leading: boolean, trailing: boolean)
}
function carriedText(text: string): InlineSegment {
return { escaping: 'attribute', text: `:text{text=${spellStringAttribute(text)}}` }
return { escaping: 'attribute', text: spellLeafDirective('text', spellAttributes([['text', spellStringAttribute(text)]])) }
}
function spellLeafDirective(name: string, attributes: string): string {
return `:${name}${attributes === '' ? '{}' : attributes}`
}
function syntax(text: string): InlineSegment {
@@ -164,7 +166,7 @@ function emitHardBreak(node: AdfNode, context: InlineContext, path: ConvertError
const empty = refuseContentAndText(node, path)
if (!empty.ok) return empty
if (context.container === 'paragraph' && !context.atBlockEnd && context.brackets !== 'directive') return success([syntax('\\\n')])
return success([syntax(':hardBreak{}')])
return success([syntax(spellLeafDirective('hardBreak', ''))])
}
function emitInlineDirective(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result<InlineSegment[]> {
@@ -173,7 +175,7 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, path: Co
const attributes = spellInlineNodeAttributes(node, directive, path)
if (!attributes.ok) return attributes
const slot = directive.slot === undefined ? undefined : node.attrs?.[directive.slot]
if (slot === undefined) return success([syntax(`:${node.type}${attributes.value === '' ? '{}' : attributes.value}`)])
if (slot === undefined) return success([syntax(spellLeafDirective(node.type, attributes.value))])
if (typeof slot !== 'string') return failure('unsupported-node-shape', `the ${node.type} attribute ${directive.slot} holds no string`, path)
if (/[\n\r]/.test(slot)) return failure('unspellable-whitespace', `a ${node.type} content slot holds a newline no inline directive spans`, path)
if (holdsNullCharacter(slot)) return failure('unspellable-character', `a ${node.type} content slot holds a null character CommonMark replaces`, path)
+6 -7
View File
@@ -1,8 +1,8 @@
import type { AdfNode } from './adf-document.ts'
import { emitPipeCell } from './markdown-inline.ts'
import { success, type ConvertErrorPath, type Result } from './result.ts'
import { tryPipeCell } from './markdown-inline.ts'
import type { ConvertErrorPath } from './result.ts'
export function emitPipeTable(node: AdfNode, path: ConvertErrorPath): Result<string> | undefined {
export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | undefined {
const rows = pipeRows(node)
if (rows === undefined) return undefined
const lines: string[] = []
@@ -10,15 +10,14 @@ export function emitPipeTable(node: AdfNode, path: ConvertErrorPath): Result<str
const cells: string[] = []
for (const [cellIndex, paragraph] of row.entries()) {
const content = paragraph.content ?? []
const line = content.length === 0 ? success('') : emitPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
if (line === undefined) return undefined
if (!line.ok) return line
cells.push(line.value)
cells.push(line)
}
lines.push(`| ${cells.join(' | ')} |`)
if (rowIndex === 0) lines.push(`| ${cells.map(() => '---').join(' | ')} |`)
}
return success(lines.join('\n'))
return lines.join('\n')
}
function pipeRows(node: AdfNode): AdfNode[][] | undefined {