3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 19:58:28 +01:00

Answer stability round 2: a run that cannot be spelled parts at its hard breaks before the carry takes it, and the recipe bullet names the content it passes

This commit is contained in:
2026-10-09 11:21:56 +02:00
committed by Lillem4n
parent 852d010ff7
commit 1d05ee3df3
6 changed files with 60 additions and 29 deletions
+5 -4
View File
@@ -71,11 +71,12 @@
once the process had read a key holding a backslash
([V8 bug](https://issues.chromium.org/issues/521080746)).
- **Breaking:** a hard break inside emphasis, strike, a link or a directive mark reads holding no
marks, as Atlassian's schema requires, where every reader gave it the marks around it.
A link whose text is only hard breaks reads as an empty link text does, as its destination
linked, and `losslessMarkdownToAdf` refuses a directive mark wrapping only hard breaks as
marks, as Atlassian's schema requires, where every reader gave it the marks around it. A link
whose text is only hard breaks reads as an empty link text does, as its destination linked, and
`losslessMarkdownToAdf` refuses a directive mark wrapping only hard breaks as
`unsupported-node-shape`. `adfToLosslessMarkdown` writes a hard break holding marks as
`!adf:carry{json="…"}`. See `MIGRATION.md`.
`!adf:carry{json="…"}`, and both writers spell an emphasis or link across the hard breaks inside
it once, `_a\` then `b_`, where they closed and reopened it. See `MIGRATION.md`.
- Fix a cyclic document, in which an object holds itself: `adfToLosslessMarkdown` and
`adfToPortableMarkdown` refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`,
where all three hung.
+2 -3
View File
@@ -46,9 +46,8 @@ function migrateMarkdown(stored: string) {
- Convert each document once: a second pass can return ok while turning the directives into text.
Stop `0.1.0` writing first, and record which documents are converted.
- A refusal carrying `position` is `0.1.0`'s parse, which refused that markdown before too. One
without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF, with
`content: parsed.value.content ?? []` as the recipe gives it, rather than keeping the unconverted
markdown.
without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF, with the `content`
the recipe passes to `adfToLosslessMarkdown`, rather than keeping the unconverted markdown.
### Spellings
+1 -1
View File
@@ -539,7 +539,7 @@ gives marks `[em, underline]`, `!adf:underline[_x_]` the reverse. `adfToLossless
the order the array holds rather than sorting it — `docs/decisions.md` §Equality is deep restores
the array, not a set — and opens each spelling once over the longest run of adjacent inline nodes
carrying an identical mark, attributes included, at that depth, an emphasis or link spelling also
spanning a hard break between two of them: `attrs: {}` differs from no `attrs`, and a directive
spanning the hard breaks between two of them: `attrs: {}` differs from no `attrs`, and a directive
spells it `{attrs=empty}`. A run breaks at every node the emitter carries, so no emitted carry sits
inside a mark spelling.
+2 -1
View File
@@ -721,7 +721,8 @@ test('spells a mark run across the hard break between two nodes holding the mark
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\\\n_b_\n')
assert.equal(readsBack(document(paragraph(marked('a', em), hardBreak, hardBreak, marked('b', em)))), '_a\\\n\\\nb_\n')
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')
})
test('carries a hard break holding marks, which Atlassian\'s schema gives it none of', () => {
+49 -20
View File
@@ -28,15 +28,18 @@ type InlineContext = {
carried: ReadonlySet<number>
flavour: WrittenFlavour
openingLinkAsDirective: boolean
parted: ReadonlySet<number>
path: ConvertErrorPath
spansLines: boolean
}
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
type InlineRun = MarkedRun | { index: number; kind: 'plain'; node: AdfNode }
type MarkedRun = { 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; openingLinkAsDirective: boolean }
type LineFallbacks = { carried: Set<number>; flavour: WrittenFlavour; openingLinkAsDirective: boolean; parted: Set<number> }
export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
@@ -53,11 +56,16 @@ export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: Conve
}
export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<PortableLineFallback | undefined> {
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')
return success(verdict.kind === 'line' ? undefined : verdict)
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)
}
}
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, flavour: WrittenFlavour): string | undefined {
@@ -76,13 +84,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 }
const fallbacks: LineFallbacks = { carried: new Set(), flavour, openingLinkAsDirective: false, parted: new Set() }
// 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, path)
const taken = takeFallback(fallbacks, emission.value.carry, nodes, path)
if (!taken.ok) return taken
continue
}
@@ -90,23 +98,34 @@ 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, path)
const taken = takeFallback(fallbacks, attempt.fallback, nodes, path)
if (!taken.ok) return taken
}
}
function takeFallback(fallbacks: LineFallbacks, fallback: NodeRange | 'opening-link', path: ConvertErrorPath): Result<null> {
function takeFallback(fallbacks: LineFallbacks, fallback: NodeRange | 'opening-link', nodes: readonly AdfNode[], 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)
}
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)
@@ -177,7 +196,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.carried)
const runs = inlineRuns(nodes, depth, firstIndex, context)
const segments: InlineSegment[] = []
for (const [offset, run] of runs.entries()) {
if (takesTextBreak(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling))
@@ -190,14 +209,14 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c
return success({ segments })
}
function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, carried: ReadonlySet<number>): InlineRun[] {
function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number, context: InlineContext): 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]
const mark = carries(node, context.carried, index) ? undefined : nodeMarks(node)[depth]
const previous = runs[runs.length - 1]
if (mark === undefined && previous?.kind === 'marked' && joinsRun(node, index, nodes[offset + 1], previous.mark, depth, carried)) {
if (mark === undefined && previous?.kind === 'marked' && joinsRun(nodes, offset, firstIndex, previous, depth, context)) {
previous.nodes.push(node)
continue
}
@@ -211,13 +230,23 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
return runs
}
// The reader marks no hard break, so one between two nodes holding an emphasis or link mark reads back inside its spelling.
function joinsRun(node: AdfNode, index: number, next: AdfNode | undefined, mark: AdfMark, depth: number, carried: ReadonlySet<number>): boolean {
if (node.type !== 'hardBreak' || carries(node, carried, index) || next === undefined || carries(next, carried, index + 1)) return false
const kind = markSpelling(mark.type)?.kind
// A directive's [content] holds no line ending, 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 it.
if (run.nodes.at(-1)?.type === 'hardBreak') return true
let ahead = offset + 1
while (joinable(ahead)) ahead += 1
const next = nodes[ahead]
if (next === undefined || carries(next, context.carried, firstIndex + ahead)) return false
const nextMark = nodeMarks(next)[depth]
return nextMark !== undefined && identicalMark(nextMark, mark)
return nextMark !== undefined && identicalMark(nextMark, run.mark)
}
function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet<number>): boolean {
@@ -310,6 +310,7 @@ test('breaks a line at a newline and trims whitespace at every edge CommonMark s
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('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')
assert.equal(portable(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n')