|
|
|
@@ -27,19 +27,17 @@ type InlineContext = {
|
|
|
|
|
bracketed: boolean
|
|
|
|
|
carried: ReadonlySet<number>
|
|
|
|
|
flavour: WrittenFlavour
|
|
|
|
|
joinsBreaks: boolean
|
|
|
|
|
openingLinkAsDirective: boolean
|
|
|
|
|
parted: ReadonlySet<number>
|
|
|
|
|
path: ConvertErrorPath
|
|
|
|
|
spansLines: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type InlineRun = MarkedRun | { index: number; kind: 'plain'; node: AdfNode }
|
|
|
|
|
|
|
|
|
|
type MarkedRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] }
|
|
|
|
|
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
|
|
|
|
|
|
|
|
|
|
type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } | { fallback?: undefined; line: string }
|
|
|
|
|
|
|
|
|
|
type LineFallbacks = { carried: Set<number>; flavour: WrittenFlavour; openingLinkAsDirective: boolean; parted: Set<number> }
|
|
|
|
|
type LineFallbacks = { carried: Set<number>; flavour: WrittenFlavour; joinsBreaks: boolean; openingLinkAsDirective: boolean }
|
|
|
|
|
|
|
|
|
|
export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
|
|
|
|
|
|
|
|
|
@@ -56,16 +54,12 @@ export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: Conve
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<PortableLineFallback | undefined> {
|
|
|
|
|
const fallbacks: LineFallbacks = { carried: new Set(), flavour: 'portable', openingLinkAsDirective: false, parted: new Set() }
|
|
|
|
|
// Terminates because each pass parts a hard break the last one joined.
|
|
|
|
|
for (;;) {
|
|
|
|
|
const emission = lineSegments(nodes, container, path, fallbacks)
|
|
|
|
|
if (!emission.ok) return emission
|
|
|
|
|
if (emission.value.carry !== 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')
|
|
|
|
|
if (verdict.kind === 'unspellable-run' && partJoinedBreaks(fallbacks, nodes, verdict.runs[0])) continue
|
|
|
|
|
return success(verdict.kind === 'line' ? undefined : verdict)
|
|
|
|
|
}
|
|
|
|
|
// Unjoined: the spelling `emitLine` falls back to where the joined one fails.
|
|
|
|
|
const emission = lineSegments(nodes, container, path, { carried: new Set(), flavour: 'portable', joinsBreaks: false, openingLinkAsDirective: false })
|
|
|
|
|
if (!emission.ok) return emission
|
|
|
|
|
if (emission.value.carry !== 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')
|
|
|
|
|
return success(verdict.kind === 'line' ? undefined : verdict)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, flavour: WrittenFlavour): string | undefined {
|
|
|
|
@@ -84,13 +78,13 @@ export function tryImageLine(alt: string | undefined, href: string): string | un
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result<EmittedLine> {
|
|
|
|
|
const fallbacks: LineFallbacks = { carried: new Set(), flavour, openingLinkAsDirective: false, parted: new Set() }
|
|
|
|
|
const fallbacks: LineFallbacks = { carried: new Set(), flavour, joinsBreaks: nodes.some((node) => node.type === 'hardBreak'), openingLinkAsDirective: false }
|
|
|
|
|
// Terminates because takeFallback refuses a pass that took no new fallback.
|
|
|
|
|
for (;;) {
|
|
|
|
|
const emission = lineSegments(nodes, container, path, fallbacks)
|
|
|
|
|
if (!emission.ok) return emission
|
|
|
|
|
if (emission.value.carry !== undefined) {
|
|
|
|
|
const taken = takeFallback(fallbacks, emission.value.carry, nodes, path)
|
|
|
|
|
const taken = takeFallback(fallbacks, emission.value.carry, path)
|
|
|
|
|
if (!taken.ok) return taken
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
@@ -98,34 +92,28 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con
|
|
|
|
|
if (attempt.line !== undefined) {
|
|
|
|
|
return success({ line: attempt.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
|
|
|
|
|
}
|
|
|
|
|
const taken = takeFallback(fallbacks, attempt.fallback, nodes, path)
|
|
|
|
|
const taken = takeFallback(fallbacks, attempt.fallback, path)
|
|
|
|
|
if (!taken.ok) return taken
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function takeFallback(fallbacks: LineFallbacks, fallback: NodeRange | 'opening-link', nodes: readonly AdfNode[], path: ConvertErrorPath): Result<null> {
|
|
|
|
|
// A line's first fallback stops runs joining across hard breaks, so no fallback takes a break `joinedBreaks` joined.
|
|
|
|
|
function takeFallback(fallbacks: LineFallbacks, fallback: NodeRange | 'opening-link', path: ConvertErrorPath): Result<null> {
|
|
|
|
|
if (fallbacks.joinsBreaks) {
|
|
|
|
|
fallbacks.joinsBreaks = false
|
|
|
|
|
return success(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)
|
|
|
|
|
}
|
|
|
|
|
if (partJoinedBreaks(fallbacks, nodes, fallback)) return success(null)
|
|
|
|
|
const before = fallbacks.carried.size
|
|
|
|
|
for (let index = fallback.first; index <= fallback.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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// An unmarked hard break inside a range is one `joinsRun` joined; parting the run there spares both sides the fallback.
|
|
|
|
|
function partJoinedBreaks(fallbacks: LineFallbacks, nodes: readonly AdfNode[], range: NodeRange): boolean {
|
|
|
|
|
const before = fallbacks.parted.size
|
|
|
|
|
for (let index = range.first; index <= range.last; index += 1) {
|
|
|
|
|
const node = nodes[index]
|
|
|
|
|
if (node?.type === 'hardBreak' && nodeMarks(node).length === 0 && !fallbacks.carried.has(index)) fallbacks.parted.add(index)
|
|
|
|
|
}
|
|
|
|
|
return fallbacks.parted.size > before
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result<Emission> {
|
|
|
|
|
const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, path, spansLines: container === 'paragraph' }
|
|
|
|
|
const emission = emitRun(nodes, 0, 0, context)
|
|
|
|
@@ -211,13 +199,17 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c
|
|
|
|
|
|
|
|
|
|
function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): InlineRun[] {
|
|
|
|
|
const runs: InlineRun[] = []
|
|
|
|
|
for (const [offset, node] of nodes.entries()) {
|
|
|
|
|
for (let offset = 0; offset < nodes.length; offset += 1) {
|
|
|
|
|
const node = nodes[offset]
|
|
|
|
|
if (node === undefined) continue
|
|
|
|
|
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, context.carried, index) ? undefined : nodeMarks(node)[depth]
|
|
|
|
|
const previous = runs[runs.length - 1]
|
|
|
|
|
if (mark === undefined && previous?.kind === 'marked' && joinsRun(nodes, offset, firstIndex, previous, depth, context)) {
|
|
|
|
|
previous.nodes.push(node)
|
|
|
|
|
const joined = mark === undefined && previous?.kind === 'marked' && context.joinsBreaks ? joinedBreaks(nodes, offset, firstIndex, previous.mark, depth, context.carried) : 0
|
|
|
|
|
if (previous?.kind === 'marked' && joined > 0) {
|
|
|
|
|
for (const held of nodes.slice(offset, offset + joined)) previous.nodes.push(held)
|
|
|
|
|
offset += joined - 1
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if (mark === undefined) {
|
|
|
|
@@ -230,23 +222,20 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
|
|
|
|
|
return runs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A directive's [content] holds no line ending, and `!adf:hardBreak{}` there reads worse than closing and reopening it, so only an emphasis or link spelling spans the hard breaks between two nodes it marks.
|
|
|
|
|
function joinsRun(nodes: readonly AdfNode[], offset: number, firstIndex: number, run: MarkedRun, depth: number, context: InlineContext): boolean {
|
|
|
|
|
const kind = markSpelling(run.mark.type)?.kind
|
|
|
|
|
if (kind !== 'emphasis' && kind !== 'link') return false
|
|
|
|
|
const joinable = (at: number): boolean => {
|
|
|
|
|
const node = nodes[at]
|
|
|
|
|
return node?.type === 'hardBreak' && !carries(node, context.carried, firstIndex + at) && !context.parted.has(firstIndex + at)
|
|
|
|
|
}
|
|
|
|
|
if (!joinable(offset)) return false
|
|
|
|
|
// A later break in a sequence joins because the first one found the node closing the run.
|
|
|
|
|
if (run.nodes.at(-1)?.type === 'hardBreak') return true
|
|
|
|
|
let ahead = offset + 1
|
|
|
|
|
while (joinable(ahead)) ahead += 1
|
|
|
|
|
// The hard breaks from `offset` an emphasis or link run spans because the node after them holds its mark; a directive would spell each `!adf:hardBreak{}`.
|
|
|
|
|
function joinedBreaks(nodes: readonly AdfNode[], offset: number, firstIndex: number, mark: AdfMark, depth: number, carried: ReadonlySet<number>): number {
|
|
|
|
|
const kind = markSpelling(mark.type)?.kind
|
|
|
|
|
if (kind !== 'emphasis' && kind !== 'link') return 0
|
|
|
|
|
let ahead = offset
|
|
|
|
|
while (isUncarriedBreak(nodes[ahead], carried, firstIndex + ahead)) ahead += 1
|
|
|
|
|
const next = nodes[ahead]
|
|
|
|
|
if (next === undefined || carries(next, context.carried, firstIndex + ahead)) return false
|
|
|
|
|
if (next === undefined || carries(next, carried, firstIndex + ahead)) return 0
|
|
|
|
|
const nextMark = nodeMarks(next)[depth]
|
|
|
|
|
return nextMark !== undefined && identicalMark(nextMark, run.mark)
|
|
|
|
|
return nextMark !== undefined && identicalMark(nextMark, mark) ? ahead - offset : 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isUncarriedBreak(node: AdfNode | undefined, carried: ReadonlySet<number>, index: number): boolean {
|
|
|
|
|
return node?.type === 'hardBreak' && !carries(node, carried, index)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet<number>): boolean {
|
|
|
|
@@ -264,7 +253,7 @@ function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): bo
|
|
|
|
|
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 || (!model.marks && nodeMarks(node).length > 0)) return true
|
|
|
|
|
if (model === undefined || (!model.takesMarks && nodeMarks(node).length > 0)) 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
|
|
|
|
|
}
|
|
|
|
@@ -324,11 +313,9 @@ function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number,
|
|
|
|
|
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 })
|
|
|
|
|
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
|
|
|
|
|
if (!inner.ok) return inner
|
|
|
|
|
if (inner.value.carry !== undefined) return inner
|
|
|
|
|
// A link `tryLink` declined holds the hard breaks `joinsRun` joined; the carry's fallback parts them.
|
|
|
|
|
if (inner.value.segments.some((segment) => segment.text.includes('\n'))) return success({ carry: range })
|
|
|
|
|
return success({ segments: [syntax(spellInlineDirectiveOpener(mark.type)), ...inner.value.segments, syntax(`]${attributes}`)] })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|