Answer the architecture review: emit-only spellings move down, and the tables drop markdown's words
CI / gate (push) Successful in 5s

This commit is contained in:
2026-08-27 22:37:11 +02:00
parent e580eec09b
commit 559922e327
14 changed files with 125 additions and 82 deletions
+5 -5
View File
@@ -9,7 +9,7 @@ import { failure, success, type ConvertErrorPath, type Result } from '../../resu
import { fencedCodeBlock } from '../backtick-runs.ts'
import { holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isThematicBreak } from '../commonmark-grammar.ts'
import { largestNesting } from '../../nesting.ts'
import { spellDirectiveHeader } from '../block-directive-spelling.ts'
import { spellDirectiveHeader } from './block-directive-spelling.ts'
import { tryImage } from './image.ts'
import { tryPipeTable } from './pipe-table.ts'
@@ -117,14 +117,14 @@ function commonMarkText(text: string): EmittedBlock {
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path)
const content = node.content ?? []
if (directive.body === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path)
if (directive.body === 'code') return emitCodeDirective(node, directive, path)
if (directive.contentModel === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path)
if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path)
const header = spellDirectiveHeader(node, directive)
if (header === undefined) return commonMarkLine(carriedBlock(node, path))
if (directive.body === 'none' || (directive.body === 'inline' && content.length === 0)) {
if (directive.contentModel === 'none' || (directive.contentModel === 'inline' && content.length === 0)) {
return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` })
}
const body = directive.body === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1)
const body = directive.contentModel === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1)
if (!body.ok) return body
const fenceColons = Math.max(3, body.value.fenceColons + 1)
const fence = ':'.repeat(fenceColons)
@@ -0,0 +1,34 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { BlockDirective } from '../../adf/block-directives.ts'
import type { JsonValue } from '../../json-value.ts'
import { blockArgument } from '../block-directive-arguments.ts'
import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-attributes.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined {
const argumentAttribute = blockArgument(node.type)
const argument = spellArgument(node, argumentAttribute)
if (argument === undefined) return undefined
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, spelled)
if (pairs === undefined) return undefined
const spelledPairs = spellVocabulary(pairs)
const marks = node.marks ?? []
if (marks.length > 0) spelledPairs.push(['marks', spellJsonAttribute(markValues(marks))])
const attributes = spellAttributes(spelledPairs)
return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}`
}
function spellArgument(node: AdfNode, argumentAttribute: string | undefined): string | undefined {
const value = argumentAttribute === undefined ? undefined : node.attrs?.[argumentAttribute]
if (value === undefined) return ''
if (typeof value !== 'string' || !isBareToken(value)) return undefined
return ` ${value}`
}
function markValues(marks: readonly AdfMark[]): JsonValue {
return marks.map((mark) => {
const attrs = mark.attrs ?? {}
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
})
}
+37
View File
@@ -0,0 +1,37 @@
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { holdsControlCharacter, holdsEntityReference } from '../commonmark-grammar.ts'
export function spellDestination(href: string, path: ConvertErrorPath): Result<string> {
if (holdsControlCharacter(href)) return failure('unspellable-link-destination', 'a link destination holds a control character', path)
if (href.includes('\\')) return failure('unspellable-link-destination', 'no canonical escape spells a backslash in a link destination', path)
if (holdsEntityReference(href)) {
return failure('unspellable-link-destination', 'a link destination shaped like an entity reference decodes on the way back', path)
}
if (href.includes(' ')) {
if (/[<>]/.test(href)) {
return failure('unspellable-link-destination', 'no canonical escape spells an angle bracket beside a space in a link destination', path)
}
return success(`<${href}>`)
}
if (href.startsWith('<')) return failure('unspellable-link-destination', 'a bare link destination cannot begin with an angle bracket', path)
if (!balanced(href)) return failure('unspellable-link-destination', 'no canonical escape spells an unbalanced parenthesis in a link destination', path)
return success(href)
}
export function spellTitle(title: string, path: ConvertErrorPath): Result<string> {
if (/["\n\r\\]/.test(title)) {
return failure('unspellable-link-title', 'no canonical escape spells a quote, backslash or newline in a link title', path)
}
if (holdsEntityReference(title)) return failure('unspellable-link-title', 'a link title shaped like an entity reference decodes on the way back', path)
return success(` "${title}"`)
}
function balanced(href: string): boolean {
let depth = 0
for (const character of href) {
if (character === '(') depth += 1
if (character === ')') depth -= 1
if (depth < 0) return false
}
return depth === 0
}
@@ -0,0 +1,9 @@
import type { AdfNode } from '../../adf/document.ts'
import type { InlineDirective } from '../../adf/inline-directives.ts'
import { spellAttributes, spellVocabulary } from '../directive-attributes.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined {
const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.textAttribute === undefined ? [] : [directive.textAttribute])
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
}
+3 -3
View File
@@ -10,8 +10,8 @@ import { longestBacktickRun } from '../backtick-runs.ts'
import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts'
import { spellAttributes, spellStringAttribute } from '../directive-attributes.ts'
import { spellDestination, spellTitle } from '../destination-spelling.ts'
import { spellInlineNodeAttributes } from '../inline-directive-spelling.ts'
import { spellDestination, spellTitle } from './destination-spelling.ts'
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
type EmittedLine = { line: string; segments: InlineSegment[] }
@@ -209,7 +209,7 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n
if (!empty.ok) return empty
const attributes = spellInlineNodeAttributes(node, directive)
if (attributes === undefined) return success({ carry: { first: index, last: index } })
const slot = directive.slot === undefined ? undefined : node.attrs?.[directive.slot]
const slot = directive.textAttribute === undefined ? undefined : node.attrs?.[directive.textAttribute]
if (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] })
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
if (/[\n\r]/.test(slot)) return failure('unspellable-whitespace', `a ${node.type} content slot holds a newline no inline directive spans`, path)
+12 -7
View File
@@ -1,5 +1,13 @@
import { escapesLineClaim, opensBracketedAutolink, startsEntityReference, type LinePosition } from '../commonmark-grammar.ts'
import { delimiterFlags, isWordCharacter, matchEmphasis } from '../emphasis-matching.ts'
import {
escapesLineClaim,
isAsciiPunctuation,
opensBracketedAutolink,
opensHtmlConstruct,
startsEntityReference,
type LinePosition,
} from '../commonmark-grammar.ts'
import { opensInlineDirective } from '../directive-attributes.ts'
export type EmphasisRole = 'close' | 'open'
@@ -21,9 +29,6 @@ type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; deli
const delimiters = ['*', '_', '`', '~']
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/]
const inlineDirectiveOpener = /^:[a-z][A-Za-z0-9]*[[{]/
const followsLinkText = /[([:]/
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
@@ -211,10 +216,10 @@ function claimsCharacter(
const rest = scan.slice(index)
if (inBrackets && (character === '[' || character === ']')) return true
if (character === '|') return container === 'table-cell'
if (character === '\\') return asciiPunctuation.test(scan.charAt(index + 1))
if (character === '\\') return isAsciiPunctuation(scan.charAt(index + 1))
if (character === '&') return startsEntityReference(rest)
if (character === '<') return opensBracketedAutolink(rest) || htmlConstructs.some((construct) => construct.test(rest))
if (character === ':') return inlineDirectiveOpener.test(rest)
if (character === '<') return opensBracketedAutolink(rest) || opensHtmlConstruct(rest)
if (character === ':') return opensInlineDirective(rest)
if (character === '[') return opensLink(scan, escapings, index)
if (character === '`') return opensCodeSpan(scan, index, escaped)
if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, escaped)