3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-14 12:30:26 +01:00

Answer architecture round 4: the writers close a mark run at a hard break again, and item 91 plans the joined spelling

This commit is contained in:
2026-10-09 12:00:40 +02:00
committed by Lillem4n
parent b3f4d0f5c1
commit ba21c7a462
6 changed files with 32 additions and 55 deletions
+6 -6
View File
@@ -713,17 +713,17 @@ test('spells the directive marks around the longest run they cover', () => {
assert.equal(emitted(marked('a', underline), { type: 'hardBreak' }, marked('b', underline)), '!adf:underline[a]\\\n!adf:underline[b]\n')
})
test('spells a mark run across the hard break between two nodes holding the mark', () => {
test('closes a mark run at the hard break between two nodes holding the mark', () => {
const em: AdfMark = { type: 'em' }
const link: AdfMark = { attrs: { href: '/u' }, type: 'link' }
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
const hardBreak: AdfNode = { type: 'hardBreak' }
assert.equal(readsBack(document(paragraph(marked('a', em), hardBreak, marked('b', em)))), '_a\\\nb_\n')
assert.equal(readsBack(document(paragraph(marked('a', link), hardBreak, marked('b', link)))), '[a\\\nb](/u)\n')
assert.equal(readsBack(document(paragraph(marked('a', em, link), hardBreak, marked('b', em)))), '_[a](/u)\\\nb_\n')
assert.equal(readsBack(document(paragraph(marked('a', em), hardBreak, hardBreak, marked('b', em)))), '_a\\\n\\\nb_\n')
assert.equal(readsBack(document(paragraph(marked('a', em), hardBreak, marked('b', em)))), '_a_\\\n_b_\n')
assert.equal(readsBack(document(paragraph(marked('a', link), hardBreak, marked('b', link)))), '[a](/u)\\\n[b](/u)\n')
assert.equal(readsBack(document(paragraph(marked('a', em, link), hardBreak, marked('b', em)))), '_[a](/u)_\\\n_b_\n')
assert.equal(readsBack(document(paragraph(marked('a', em), hardBreak, hardBreak, marked('b', em)))), '_a_\\\n\\\n_b_\n')
const directive: AdfMark = { attrs: { href: '/u', id: 'x' }, type: 'link' }
assert.equal(readsBack(document(paragraph(marked('a', directive), hardBreak, marked('b', directive)))), '!adf:link[a!adf:hardBreak{}b]{href="/u" id=x}\n')
assert.equal(readsBack(document(paragraph(marked('a', directive), hardBreak, marked('b', directive)))), '!adf:link[a]{href="/u" id=x}\\\n!adf:link[b]{href="/u" id=x}\n')
readsBack(document(paragraph(marked(']: /x', link, { type: 'code' }), hardBreak, marked('b', link))))
assert.equal(readsBack(document(paragraph({ text: 'x', type: 'text' }, marked('.', em), hardBreak, marked('b', em)))), 'x!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"em\\"}],\\"text\\":\\".\\",\\"type\\":\\"text\\"}"}\\\n_b_\n')
})
+9 -40
View File
@@ -27,7 +27,6 @@ type InlineContext = {
bracketed: boolean
carried: ReadonlySet<number>
flavour: WrittenFlavour
joinsBreaks: boolean
openingLinkAsDirective: boolean
path: ConvertErrorPath
spansLines: boolean
@@ -37,7 +36,7 @@ type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[
type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } | { fallback?: undefined; line: string }
type LineFallbacks = { carried: Set<number>; flavour: WrittenFlavour; joinsBreaks: boolean; openingLinkAsDirective: boolean }
type LineFallbacks = { carried: Set<number>; flavour: WrittenFlavour; openingLinkAsDirective: boolean }
export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
@@ -54,8 +53,7 @@ export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: Conve
}
export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<PortableLineFallback | undefined> {
// 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 })
const emission = lineSegments(nodes, container, path, { carried: new Set(), flavour: 'portable', 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')
@@ -78,7 +76,7 @@ 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, joinsBreaks: nodes.some((node) => node.type === 'hardBreak'), openingLinkAsDirective: false }
const fallbacks: LineFallbacks = { carried: new Set(), flavour, openingLinkAsDirective: false }
// Terminates because takeFallback refuses a pass that took no new fallback.
for (;;) {
const emission = lineSegments(nodes, container, path, fallbacks)
@@ -97,12 +95,7 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con
}
}
// 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
@@ -184,7 +177,7 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c
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)
const runs = inlineRuns(nodes, depth, firstIndex, context.carried)
const segments: InlineSegment[] = []
for (const [offset, run] of runs.entries()) {
if (takesTextBreak(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling))
@@ -197,47 +190,23 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c
return success({ segments })
}
function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): InlineRun[] {
function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet<number>): InlineRun[] {
const runs: InlineRun[] = []
for (let offset = 0; offset < nodes.length; offset += 1) {
const node = nodes[offset]
if (node === undefined) continue
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, context.carried, index) ? undefined : nodeMarks(node)[depth]
const previous = runs[runs.length - 1]
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
}
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
}
// 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, carried, firstIndex + ahead)) return 0
const nextMark = nodeMarks(next)[depth]
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 {
if (previous?.kind !== 'plain' || run.kind !== 'plain') return false
if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false
@@ -313,7 +282,7 @@ 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, spansLines: false })
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true })
if (!inner.ok) return inner
if (inner.value.carry !== undefined) return inner
return success({ segments: [syntax(spellInlineDirectiveOpener(mark.type)), ...inner.value.segments, syntax(`]${attributes}`)] })
@@ -309,7 +309,7 @@ test('breaks a line at a newline and trims whitespace at every edge CommonMark s
assert.equal(portable(paragraph(text(' \n a \n b\n'))), 'a\\\nb\n')
assert.equal(portable(paragraph({ type: 'hardBreak' }, text('a'), { attrs: { text: '\n' }, type: 'hardBreak' }, text('b'), { type: 'hardBreak' })), 'a\\\nb\n')
assert.equal(portable(paragraph(text('a'), text(' b ', strong), text('c'))), 'a **b** c\n')
assert.equal(portable(paragraph(text(' '), text('b', strong), text(' \n'), text('c', strong), text(' '))), '**b\\\nc**\n')
assert.equal(portable(paragraph(text(' '), text('b', strong), text(' \n'), text('c', strong), text(' '))), '**b**\\\n**c**\n')
assert.equal(portable(paragraph(text('x'), text('.', em), { type: 'hardBreak' }, text('b', em))), 'x.\\\n_b_\n')
assert.equal(portable(paragraph(text('a'), text(' b ', em, strong), text(' ', em), text('c', em))), 'a _**b** c_\n')
assert.equal(portable(paragraph(text(' x ', code))), '` x `\n')