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:
+5
-4
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user