3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-14 20:40:25 +01:00
Files
adf-codec/src/markdown/emit/inline-line.ts
T

389 lines
22 KiB
TypeScript

import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
import type { LineContainer } from '../line-container.ts'
import type { LinkDefinition, TitleEscapes } from '../commonmark/link-syntax.ts'
import type { WrittenFlavour } from '../flavour.ts'
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange, type RunFallback } from './line-escaping.ts'
import { carriedInline } from '../opaque-carry.ts'
import { commonMarkLink, isEmphasis, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { flavourClaims } from '../flavour.ts'
import { highlightDelimiter } from '../inline-tokens.ts'
import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { inlineNodeModel, takenMarkTypes } from '../../adf/inline-nodes.ts'
import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts'
import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
import { slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive/syntax.ts'
import { spellInlineNodeAttributes } from '../directive/inline-spelling.ts'
import { spellTextDirective } from '../directive/text.ts'
type EmittedLine = { line: string; openingLinkAsDirective: boolean; segments: InlineSegment[] }
// Every carry one walk finds, taken together: carrying a node changes no other carry's range.
type Emission = { carries: [NodeRange, ...NodeRange[]]; segments?: undefined } | { carries?: undefined; segments: InlineSegment[] }
type Fallback = [NodeRange, ...NodeRange[]] | 'opening-link'
type InlineContext = {
atBlockEnd: boolean
bracketed: boolean
carried: ReadonlySet<number>
container: LineContainer
flavour: WrittenFlavour
openingLinkAsDirective: boolean
path: ConvertErrorPath
spansLines: boolean
}
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
type LineAttempt = { fallback: Fallback; line?: undefined; segments?: undefined } | { fallback?: undefined; line: string; segments: InlineSegment[] }
type LineFallbacks = { carried: Set<number>; flavour: WrittenFlavour; openingLinkAsDirective: boolean }
// docs/decisions.md §After 32 passes, a line carries or drops every em, strike and strong mark.
export const readBackPasses = 32
export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result<string> {
const emitted = emitLine(nodes, container, path, flavour)
if (!emitted.ok) return emitted
return success(emitted.value.line)
}
export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<boolean> {
const emitted = emitLine(nodes, 'paragraph', path, 'lossless')
if (!emitted.ok) return emitted
return success(emitted.value.openingLinkAsDirective)
}
export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<PortableLineFallback | undefined> {
const emission = lineSegments(nodes, container, path, withheldMarkFallbacks(nodes, 'portable'))
if (!emission.ok) return emission
if (emission.value.carries !== undefined) return failure('unsupported-node-shape', 'an inline node on a portable line has no spelling but the carry', path)
const verdict = lineVerdict(emission.value.segments, container, 'portable', { kind: 'drop', nodes })
return success(verdict.kind === 'line' ? undefined : verdict)
}
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, flavour: WrittenFlavour): string | undefined {
const emitted = emitLine(nodes, 'table-cell', path, flavour)
if (!emitted.ok) return undefined
// spec/flavour.md, Tables; the portable reduction leaves a `|` in syntax only escaped, in a link title.
if (flavour === 'lossless' && emitted.value.segments.some((segment) => isSyntax(segment.escaping) && segment.text.includes('|'))) return undefined
return emitted.value.line
}
export function tryImageLine(alt: string | undefined, url: string, flavour: WrittenFlavour, link?: LinkDefinition): string | undefined {
if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined
const destination = spellDestination(url)
if (destination === undefined) return undefined
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
// Both flavours read an image's description with highlights off, so the lossless flavour's escapes read back in either.
if (link === undefined) return attemptLine([syntax('!['), ...description, syntax(`](${destination})`)], 'paragraph', 'lossless').line
const target = spellLinkTarget(link.destination, link.title, titleEscapes(flavour, 'paragraph'))
if (target === undefined) return undefined
return attemptLine([syntax('[!['), ...description, syntax(']('), bracketedLinkTarget(destination), syntax(`)](${target})`)], 'paragraph', 'lossless').line
}
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result<EmittedLine> {
const fallbacks = withheldMarkFallbacks(nodes, flavour)
// Terminates because takeFallback refuses a pass that took no new fallback.
for (let pass = 1; ; pass += 1) {
const attempt = linePass(nodes, container, path, fallbacks)
if (!attempt.ok) return attempt
const { fallback, line, segments } = attempt.value
if (fallback === undefined) return success({ line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments })
const taken = takeFallback(fallbacks, fallback, path)
if (!taken.ok) return taken
if (pass === readBackPasses) carryEveryEmphasis(fallbacks, nodes)
}
}
export function linePass(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result<LineAttempt> {
const emission = lineSegments(nodes, container, path, fallbacks)
if (!emission.ok) return emission
const { carries, segments } = emission.value
return success(segments === undefined ? { fallback: carries } : attemptLine(segments, container, fallbacks.flavour))
}
function carryEveryEmphasis(fallbacks: LineFallbacks, nodes: readonly AdfNode[]): void {
for (const [index, node] of nodes.entries()) if (nodeMarks(node).some(isEmphasis)) fallbacks.carried.add(index)
}
// Seeds the withheld-mark carries once per line, so the run at each mark depth does not rescan every node's marks.
export function withheldMarkFallbacks(nodes: readonly AdfNode[], flavour: WrittenFlavour): LineFallbacks {
const carried = new Set<number>()
for (const [index, node] of nodes.entries()) {
const taken = takenMarkTypes(node)
if (taken !== undefined && nodeMarks(node).some((mark) => !taken.includes(mark.type))) carried.add(index)
}
return { carried, flavour, openingLinkAsDirective: false }
}
export function takeFallback(fallbacks: LineFallbacks, fallback: Fallback, path: ConvertErrorPath): Result<null> {
if (fallback === 'opening-link') {
if (fallbacks.openingLinkAsDirective) return failure('unsupported-node-shape', 'an opening link spelled as a directive still reads as a link definition, so the line has no spelling left', path)
fallbacks.openingLinkAsDirective = true
return success(null)
}
const before = fallbacks.carried.size
for (const range of fallback) for (let index = range.first; index <= range.last; index += 1) fallbacks.carried.add(index)
if (fallbacks.carried.size === before) return failure('unsupported-node-shape', 'a carry took no inline node the line had not carried, so the line has no spelling left', path)
return success(null)
}
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result<Emission> {
const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, container, path, spansLines: container === 'paragraph' }
const emission = emitRun(nodes, 0, 0, context)
if (!emission.ok) return emission
if (emission.value.carries !== undefined) return emission
return success({ segments: spellEdgeWhitespace(emission.value.segments) })
}
function attemptLine(segments: InlineSegment[], container: LineContainer, flavour: WrittenFlavour): LineAttempt {
const verdict = lineVerdict(segments, container, flavour, { kind: 'carry' })
if (verdict.kind === 'opening-link') return { fallback: 'opening-link' }
if (verdict.kind === 'unspellable-run') return { fallback: verdict.runs }
return { line: verdict.text, segments }
}
// The fallbacks in the order a line takes them, or the line where it takes none.
function lineVerdict(segments: readonly InlineSegment[], container: LineContainer, flavour: WrittenFlavour, fallback: RunFallback): PortableLineFallback | { kind: 'line'; text: string } {
const assembled = assembleInlineLine(segments, container, flavour, fallback)
if (assembled.openingLinkAsDirective) return { kind: 'opening-link' }
const [run, ...others] = assembled.unspellableRuns
return run === undefined ? { kind: 'line', text: assembled.line } : { kind: 'unspellable-run', runs: [run, ...others] }
}
// A space or tab CommonMark strips at a line edge rides !adf:text{text="…"}.
function spellEdgeWhitespace(segments: readonly InlineSegment[]): InlineSegment[] {
const spelled: InlineSegment[] = []
for (const [index, segment] of segments.entries()) {
const previous = segments[index - 1]
const next = segments[index + 1]
const leading = previous === undefined || previous.text.includes('\n')
const trailing = next === undefined || next.text.includes('\n')
spelled.push(...edgeWhitespaceSegments(segment, leading, trailing))
}
return spelled
}
function edgeWhitespaceSegments(segment: InlineSegment, leading: boolean, trailing: boolean): InlineSegment[] {
if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment]
const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : ''
const body = segment.text.slice(head.length)
const middle = trailing ? trimTrailingSpace(body) : body
const tail = body.slice(middle.length)
const edges: InlineSegment[] = []
if (head !== '') edges.push(textDirectiveSegment(head))
if (middle !== '') edges.push({ escaping: segment.escaping, text: middle })
if (tail !== '') edges.push(textDirectiveSegment(tail))
return edges
}
function textDirectiveSegment(text: string): InlineSegment {
return syntax(spellTextDirective(text))
}
function syntax(text: string): InlineSegment {
return { escaping: 'none', text }
}
function titleEscapes(flavour: WrittenFlavour, container: LineContainer): TitleEscapes {
if (flavour === 'lossless') return 'lossless'
return container === 'table-cell' ? 'portable-table-cell' : 'portable'
}
function bracketedLinkTarget(target: string): InlineSegment {
return { escaping: 'bracketed-link-target', text: escapeUnbalanced(target, '[', ']') }
}
function heldContentOrText(node: AdfNode): 'content' | 'text' | undefined {
if (nodeContent(node).length > 0) return 'content'
return node.text === undefined ? undefined : 'text'
}
function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): Result<Emission> {
if (depth > largestNesting) {
return failure('unsupported-nesting-depth', `the marks nest deeper than the ${largestNesting} levels the emitter carries`, context.path)
}
const runs = inlineRuns(nodes, depth, firstIndex, context.carried)
const carries: NodeRange[] = []
const segments: InlineSegment[] = []
for (const [offset, run] of runs.entries()) {
if (takesTextBreak(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling))
const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 }
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
if (!emitted.ok) return emitted
if (emitted.value.carries !== undefined) for (const carry of emitted.value.carries) carries.push(carry)
else for (const segment of emitted.value.segments) segments.push(segment)
}
const [carry, ...others] = carries
return success(carry === undefined ? { segments } : { carries: [carry, ...others] })
}
function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet<number>): InlineRun[] {
const runs: InlineRun[] = []
for (const [offset, node] of nodes.entries()) {
const index = firstIndex + offset
// `depth` indexes each node's marks, outermost first; a carried node spells its marks inside the carry.
const mark = carries(node, carried, index) ? undefined : nodeMarks(node)[depth]
if (mark === undefined) {
runs.push({ index, kind: 'plain', node })
continue
}
const previous = runs[runs.length - 1]
if (previous?.kind === 'marked' && identicalMark(previous.mark, mark)) previous.nodes.push(node)
else runs.push({ index, kind: 'marked', mark, nodes: [node] })
}
return runs
}
function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet<number>): boolean {
if (previous?.kind !== 'plain' || run.kind !== 'plain') return false
if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false
return joinsWhenRead(previous.node, run.node)
}
function nodePath(context: InlineContext, index: number): ConvertErrorPath {
return [...context.path, 'content', index]
}
// Decides the text-node and slot-fault carries up front, `withheldMarkFallbacks` the withheld-mark ones, so a line holding many of them is emitted once.
function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean {
if (carried.has(index)) return true
if (node.type === 'text') return !isSpellableText(node) || typeof node.text !== 'string' || node.text === ''
const model = inlineNodeModel(node.type)
if (model === undefined) return true
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
return typeof slot === 'string' && slotFault(node.type, slot) !== undefined && nodeContent(node).length === 0 && node.text === undefined
}
// Builds the node's path only to refuse it: a path copies every level above, and a line may hold a million leaves.
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<Emission> {
if (carries(node, context.carried, index)) {
const carried = carriedInline(node, nodePath(context, index))
if (!carried.ok) return carried
return success({ segments: [syntax(carried.value)] })
}
const types = nodeMarks(node).map((mark) => mark.type)
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, nodePath(context, index))
const model = inlineNodeModel(node.type)
if (model === undefined) return success(emitText(node, context, index))
const held = heldContentOrText(node)
if (held !== undefined) return failure('unsupported-node-shape', `a ${node.type} node holds neither content nor text: this one holds ${held}`, nodePath(context, index))
const emission = node.type === 'hardBreak' ? emitHardBreak(node, model, context, index) : emitInlineDirective(node, model, index)
return checkedCarry(emission, [node], context)
}
// Builds a carry's JSON where the walk finds it, so its refusal comes ahead of any later node's.
function checkedCarry(emission: Emission, nodes: readonly AdfNode[], context: InlineContext): Result<Emission> {
const range = emission.carries?.[0]
if (range === undefined) return success(emission)
for (const [offset, node] of nodes.entries()) {
const carried = carriedInline(node, nodePath(context, range.first + offset))
if (!carried.ok) return carried
}
return success(emission)
}
function emitHardBreak(node: AdfNode, model: InlineNodeModel, context: InlineContext, index: number): Emission {
const attributes = spellInlineNodeAttributes(node, model)
if (attributes === undefined) return { carries: [{ first: index, last: index }] }
if (attributes === '' && context.spansLines && !context.atBlockEnd) return { segments: [syntax('\\\n')] }
return { segments: [syntax(spellInlineLeafDirective('hardBreak', attributes))] }
}
function emitInlineDirective(node: AdfNode, model: InlineNodeModel, index: number): Emission {
const attributes = spellInlineNodeAttributes(node, model)
if (attributes === undefined) return { carries: [{ first: index, last: index }] }
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
if (slot === undefined) return { segments: [syntax(spellInlineLeafDirective(node.type, attributes))] }
if (typeof slot !== 'string' || slotFault(node.type, slot) !== undefined) return { carries: [{ first: index, last: index }] }
const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }]
return { segments: [syntax(spellInlineDirectiveOpener(node.type)), ...content, syntax(`]${attributes}`)] }
}
// CommonMark reads a raw carriage return as a line ending and a null character as U+FFFD, so neither is written raw.
function emitText(node: AdfNode, context: InlineContext, index: number): Emission {
if (!isSpellableText(node) || typeof node.text !== 'string' || node.text === '') return { carries: [{ first: index, last: index }] }
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
const parts = node.text.split(/(\n+|\0+|\r)/).filter((part) => part !== '')
return { segments: parts.map((part) => (part === '\r' ? syntax('&#13;') : /^[\n\0]/.test(part) ? textDirectiveSegment(part) : { escaping, text: part })) }
}
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> {
const range: NodeRange = { first: index, last: index + nodes.length - 1 }
if (mark.type === 'backgroundColor' && flavourClaims[context.flavour].highlights) return emitHighlight(nodes, depth, range, context)
const spelling = markSpelling(mark.type)
const attributes = spelling === undefined ? undefined : spellMarkAttributes(mark, spelling)
if (spelling === undefined || attributes === undefined) return checkedCarry({ carries: [range] }, nodes, context)
if (spelling.kind === 'code') return checkedCarry(emitCodeSpan(nodes, depth, range), nodes, context)
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined
if (link !== undefined) return link
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
if (!inner.ok) return inner
if (inner.value.carries !== undefined) return inner
return success({ segments: [syntax(spellInlineDirectiveOpener(mark.type)), ...inner.value.segments, syntax(`]${attributes}`)] })
}
function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number, range: NodeRange, context: InlineContext): Result<Emission> {
const inner = emitRun(nodes, depth + 1, range.first, context)
if (!inner.ok) return inner
if (inner.value.carries !== undefined) return inner
const spelled = spellEdgeWhitespace(inner.value.segments)
return success({
segments: [
{ emphasis: 'open', escaping: 'none', nodes: { ...range, depth }, text: spelling },
...spelled,
{ emphasis: 'close', escaping: 'none', nodes: { ...range, depth }, text: spelling },
],
})
}
function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRange, context: InlineContext): Result<Emission> {
const inner = emitRun(nodes, depth + 1, range.first, context)
if (!inner.ok || inner.value.carries !== undefined) return inner
const run = { ...range, depth }
return success({
segments: [{ escaping: 'none', highlight: 'open', nodes: run, text: highlightDelimiter }, ...inner.value.segments, { escaping: 'none', highlight: 'close', nodes: run, text: highlightDelimiter }],
})
}
// Each node its own span: CommonMark reads two adjacent text nodes in one span back as one.
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange): Emission {
const spans: string[] = []
for (const node of nodes) {
const { text } = node
if (!isSpellableText(node) || nodeMarks(node).length !== depth + 1 || typeof text !== 'string' || text === '' || /[\n\r]/.test(text) || holdsNullCharacter(text)) return { carries: [range] }
const fence = '`'.repeat(longestBacktickRun(text) + 1)
spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`)
}
return { segments: [syntax(spans.join(textBreakSpelling))] }
}
function needsPadding(text: string): boolean {
if (text.startsWith('`') || text.endsWith('`')) return true
return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text)
}
function tryLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result<Emission> | undefined {
const href = linkHref(nodeAttrs(mark))
if (href === undefined) return checkedCarry({ carries: [range] }, nodes, context)
const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective
const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes, depth + 1, context.bracketed, titleEscapes(context.flavour, context.container))
if (commonMark === undefined) return undefined
if (commonMark.form === 'autolink') return success({ segments: [syntax(`<${href}>`)] })
const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true })
if (!inner.ok) return inner
if (inner.value.carries !== undefined) return inner
const target = context.bracketed ? bracketedLinkTarget(commonMark.target) : syntax(commonMark.target)
return success({ segments: [{ escaping: 'none', nodes: range, text: '[' }, ...inner.value.segments, syntax(']('), target, syntax(')')] })
}