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

Group a line's runs in one home, part a line as its first fallback, and trim each portable line by its own runs

This commit is contained in:
2026-11-03 23:36:47 +01:00
committed by Lillem4n
parent d15d063c43
commit bab2ffcfb8
13 changed files with 222 additions and 189 deletions
+3 -2
View File
@@ -39,8 +39,9 @@
span: ``**Run `npm ci` first**``. `adfToLosslessMarkdown` also writes those three once across
mentions, emoji, dates, statuses, inline cards and inline media, as
`**Ping !adf:mention[@A]{id=x} now**`, and a link once across mentions, emoji, dates, statuses
and inline cards. The markdown reads back to the same ADF as before. Where the joined line would
not, the writers write the line as before.
and inline cards. The markdown reads back to the same ADF as before, except that
`adfToPortableMarkdown` drops whitespace beside a hard break a mark crosses, where CommonMark
strips it. Where the joined line would not read back, the writers write the line as before.
- Every reader keeps the URL and title of an image inside a link's text, where it dropped them:
after the link comes `(image)`, which links to the image, so
`[look at ![the moon](moon.png "Full") tonight](/moon)` reads as
+1 -1
View File
@@ -484,7 +484,7 @@ saving what this pair read replaces mentions, attachments and macros with text.
read back wrong after 32 tries drops every `em`, `strike` and `strong` at once. Code text keeps
no mark but `code` and `link`.
- A newline in text is a hard break and in an expand's title a space, edge whitespace outside a
link or code span is trimmed, carriage returns and null characters are removed, and an empty
link or code span, or beside a hard break a link crosses, is trimmed, carriage returns and null characters are removed, and an empty
paragraph drops.
- A text node's `content` drops: Atlassian's schema forbids it, so no rendered document shows it.
- Adjacent lists of a kind alternate markers, `- ` and `* `, `1.` and `1)`, since CommonMark
+3 -2
View File
@@ -719,8 +719,9 @@ and every ruling stays in the history. Each restructure runs one rung higher; pa
the maintainer decides. A ruling judges the code, never the behaviour: item 91's spanning stays, since the
paragraphs it spells, such as bold around a code span, occur in documents all the time
(2026-11-03). The maintainer's call on how: refactor first, ruled alone (item 166), then make spanning part of
grouping, with the span rule in one home. `runSpans`, beside `insideRun`, says which node a run passes. `inlineRuns` ends
a run at its last holder, and the reducer asks that same grouping through `runsSharedWithNext`. `withoutOpeningLinkCode`
grouping, with the span rule in one home. `inlineRuns` in `emit/inline-runs.ts` groups a line, `runCrosses` says which
node a run passes, and a run ends at its last holder. Parting is a line's first fallback, so each pass walks once. The reducer
judges the spanned line, then the parted line, each trimmed by its own runs through that same grouping. `withoutOpeningLinkCode`
stays on `insideRun` alone, since it answers the parted line. A refactor that merges as its own chunk took its own ruling,
so the chunk's later rulings read against that merge, the commit `larv-review ruling-base` prints. A restructure past a
pass's round cap still goes unreviewed, so the PR summary names it.
+7 -6
View File
@@ -564,12 +564,13 @@ marks array in order, outermost first: `_!adf:underline[x]_` gives marks `[em, u
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: `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. An `em`, `strike`, `strong` or `link` run also spans a
node between two of its nodes when that node cannot hold the run's mark. It does so only where the
spanned line reads back to the same nodes; otherwise the run breaks there. So `_One\` then `Two_`
is one `em` run over two texts, the hard break between them unmarked, and
``**Run `npm ci` first**`` is one `strong` run around unmarked code. `adfToPortableMarkdown` spans
hard breaks and code spans the same way.
emitted carry sits inside a mark spelling. An `em`, `strike`, `strong` or `link` run also crosses a
node between two of its nodes when that node cannot hold the run's mark, unless the node holds an
empty `marks`, which no spelling inside a mark holds. So `_One\` then `Two_` is one `em` run over two
texts, the hard break between them unmarked, and ``**Run `npm ci` first**`` is one `strong` run
around unmarked code. A line that needs any fallback, a carry or a dropped mark, breaks every run on
it at those nodes instead. `adfToPortableMarkdown` crosses hard breaks and code spans the same way,
and drops whitespace beside a hard break a run crosses, where CommonMark strips it.
An inline node whose marks no nesting spells — a node holding a mark Atlassian's schema withholds
from it, a mark type the first list above does not hold, an attrs key its spelling does not list, a value that is not the
@@ -927,6 +927,15 @@ test('spells an emphasis or link run once across the hard breaks between two nod
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('parts every run on a line that needs a fallback, whichever run needs it', () => {
const em: AdfMark = { type: 'em' }
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
assert.equal(
readsBack(document(paragraph({ text: 'x', type: 'text' }, marked('.', { type: 'strong' }), { text: 'y ', type: 'text' }, marked('a', em), { type: 'hardBreak' }, marked('b', em)))),
'x!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"text\\":\\".\\",\\"type\\":\\"text\\"}"}y _a_\\\n_b_\n',
)
})
test('spells an emphasis run once across the code spans, mentions, dates and inline media between two nodes holding the mark', () => {
const strong: AdfMark = { type: 'strong' }
const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' })
+32 -107
View File
@@ -1,13 +1,14 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
import type { InlineRun, RunGrouping, RunNode } from './inline-runs.ts'
import type { LineContainer } from '../line-container.ts'
import type { LinkDefinition, TitleEscapes } from '../commonmark/link-syntax.ts'
import type { RunFallback } from './unspellable-runs.ts'
import type { WrittenFlavour } from '../flavour.ts'
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts'
import { carriedInline } from '../opaque-carry.ts'
import { commonMarkLink, insideRun, isEmphasis, linkHref, markSpelling, runSpans, spellMarkAttributes, type RunNode } from '../mark-spellings.ts'
import { directivePrefix, slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive/syntax.ts'
import { carries, inlineRuns } from './inline-runs.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'
@@ -18,6 +19,7 @@ import { isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/do
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'
@@ -28,26 +30,20 @@ type Emission = { carries: [NodeRange, ...NodeRange[]]; segments?: undefined } |
type Fallback = [NodeRange, ...NodeRange[]] | 'opening-link'
type InlineContext = {
type InlineContext = RunGrouping & {
atBlockEnd: boolean
bracketed: boolean
carried: ReadonlySet<number>
container: LineContainer
flavour: WrittenFlavour
lineNodes: readonly AdfNode[]
openingLinkAsDirective: boolean
path: ConvertErrorPath
spans: boolean
spansLines: boolean
}
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: RunNode[] } | { 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 }
type RunGrouping = { carried: ReadonlySet<number>; spans: boolean }
type LineFallbacks = { carried: Set<number>; flavour: WrittenFlavour; openingLinkAsDirective: boolean; parted: boolean }
// docs/decisions.md §After 32 passes, a line carries or drops every em, strike and strong mark.
export const readBackPasses = 32
@@ -66,9 +62,9 @@ export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: Conve
return success(emitted.value.openingLinkAsDirective)
}
export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<PortableLineFallback | undefined> {
const fallbacks = withheldMarkFallbacks(nodes, 'portable')
const emission = lineSegments(nodes, container, path, fallbacks, false)
export function portableLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, { parted }: Pick<RunGrouping, 'parted'>): Result<PortableLineFallback | undefined> {
const fallbacks = { ...withheldMarkFallbacks(nodes, 'portable'), parted }
const emission = lineSegments(nodes, container, path, fallbacks)
if (!emission.ok) return emission
if (fallbacks.carried.size > 0 || 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 })
@@ -97,8 +93,8 @@ export function tryImageLine(alt: string | undefined, url: string, flavour: Writ
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) {
// Terminates because takeFallback refuses a pass that took no new fallback. Pass 0 only parts the line, so `readBackPasses` counts what a parted line takes.
for (let pass = 0; ; pass += 1) {
const attempt = linePass(nodes, container, path, fallbacks)
if (!attempt.ok) return attempt
const { fallback, line, segments } = attempt.value
@@ -109,20 +105,8 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con
}
}
// spec/flavour.md, Marks: a pass writes the spanned line where it needs no fallback; otherwise it writes the parted line and takes that line's fallbacks.
export function linePass(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result<LineAttempt> {
const spanned = lineAttempt(nodes, container, path, fallbacks, true)
return spanned.ok && writesSpanned(spanned.value, fallbacks.flavour) ? spanned : lineAttempt(nodes, container, path, fallbacks, false)
}
function writesSpanned({ fallback, segments }: LineAttempt, flavour: WrittenFlavour): boolean {
if (fallback !== undefined) return false
// A span can leave whitespace at a line edge, which only `!adf:text` spells.
return flavourClaims[flavour].directives || !segments.some((segment) => segment.escaping === 'none' && segment.text.startsWith(directivePrefix))
}
function lineAttempt(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks, spans: boolean): Result<LineAttempt> {
const emission = lineSegments(nodes, container, path, fallbacks, spans)
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))
@@ -139,10 +123,15 @@ export function withheldMarkFallbacks(nodes: readonly AdfNode[], flavour: Writte
const taken = takenMarkTypes(node)
if (taken !== undefined && nodeMarks(node).some((mark) => !taken.includes(mark.type))) carried.add(index)
}
return { carried, flavour, openingLinkAsDirective: false }
return { carried, flavour, openingLinkAsDirective: false, parted: false }
}
export function takeFallback(fallbacks: LineFallbacks, fallback: Fallback, path: ConvertErrorPath): Result<null> {
// spec/flavour.md, Marks: a line needing a fallback first parts its runs, and takes what the parted line still needs.
if (!fallbacks.parted) {
fallbacks.parted = true
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
@@ -154,12 +143,12 @@ export function takeFallback(fallbacks: LineFallbacks, fallback: Fallback, path:
return success(null)
}
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks, spans: boolean): Result<Emission> {
const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, container, lineNodes: nodes, path, spans, spansLines: container === 'paragraph' }
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result<Emission> {
const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, container, lineNodes: nodes, path, spansLines: container === 'paragraph' }
const emission = emitRun(nodes.map((node) => ({ node, outer: 0 })), 0, 0, context)
if (!emission.ok) return emission
if (emission.value.carries !== undefined) return emission
return success({ segments: spellEdgeWhitespace(emission.value.segments) })
return success({ segments: spellEdgeWhitespace(emission.value.segments, fallbacks.flavour) })
}
function attemptLine(segments: InlineSegment[], container: LineContainer, flavour: WrittenFlavour): LineAttempt {
@@ -177,8 +166,9 @@ function lineVerdict(segments: readonly InlineSegment[], container: LineContaine
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[] {
// A space or tab CommonMark strips at a line edge rides !adf:text{text="…"}. A flavour without directives leaves it raw, since the reducer trims each line by its own runs.
function spellEdgeWhitespace(segments: InlineSegment[], flavour: WrittenFlavour): InlineSegment[] {
if (!flavourClaims[flavour].directives) return segments
const spelled: InlineSegment[] = []
for (const [index, segment] of segments.entries()) {
const previous = segments[index - 1]
@@ -244,56 +234,6 @@ function emitRun(nodes: readonly RunNode[], depth: number, firstIndex: number, c
return success(carry === undefined ? { segments } : { carries: [carry, ...others] })
}
function inlineRuns(nodes: readonly RunNode[], firstIndex: number, grouping: RunGrouping): InlineRun[] {
const runs: InlineRun[] = []
let next = 0
for (const [offset, { node, outer }] of nodes.entries()) {
if (offset < next) continue
const index = firstIndex + offset
// A carried node spells its marks inside the carry.
const mark = carries(node, grouping.carried, index) ? undefined : nodeMarks(node)[outer]
if (mark === undefined) {
runs.push({ index, kind: 'plain', node })
continue
}
const inside = nodesInRun(nodes, offset, mark, firstIndex, grouping)
runs.push({ index, kind: 'marked', mark, nodes: inside })
next = offset + inside.length
}
return runs
}
// The run of `mark` opening at `first` ends at the last node holding the mark, so the nodes it spans past that one stay outside.
function nodesInRun(nodes: readonly RunNode[], first: number, mark: AdfMark, firstIndex: number, { carried, spans }: RunGrouping): RunNode[] {
const inside: RunNode[] = []
let holders = 0
for (let offset = first; offset < nodes.length; offset += 1) {
const runNode = nodes[offset]
if (runNode === undefined || carries(runNode.node, carried, firstIndex + offset)) break
const held = insideRun(runNode, mark)
if (held === undefined && !(spans && runSpans(runNode.node, mark))) break
inside.push(held ?? runNode)
if (held !== undefined) holders = inside.length
}
return inside.slice(0, holders)
}
// For each leaf but the last, how many runs of the spanned line hold both it and the next leaf.
export function runsSharedWithNext(leaves: readonly AdfNode[]): number[] {
const shared = leaves.map(() => 0)
countSharedRuns(leaves.map((node) => ({ node, outer: 0 })), 0, 0, shared)
return shared
}
function countSharedRuns(nodes: readonly RunNode[], depth: number, firstIndex: number, shared: number[]): void {
if (depth > largestNesting) return
for (const run of inlineRuns(nodes, firstIndex, { carried: new Set(), spans: true })) {
if (run.kind === 'plain') continue
for (let index = run.index; index < run.index + run.nodes.length - 1; index += 1) shared[index] = (shared[index] ?? 0) + 1
countSharedRuns(run.nodes, depth + 1, run.index, shared)
}
}
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
@@ -304,16 +244,6 @@ 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)) {
@@ -366,7 +296,7 @@ function textSegments(node: AdfNode, context: InlineContext): InlineSegment[] |
function emitMarkedRun(nodes: readonly RunNode[], 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)
if (mark.type === 'backgroundColor' && flavourClaims[context.flavour].highlights) return emitHighlight(nodes, depth, { ...range, mark }, context)
const spelling = markSpelling(mark.type)
const attributes = spelling === undefined ? undefined : spellMarkAttributes(mark, spelling)
if (spelling === undefined || attributes === undefined) return carriedRun(range, context)
@@ -374,7 +304,7 @@ function emitMarkedRun(nodes: readonly RunNode[], mark: AdfMark, depth: number,
const spans = codeSpans(nodes)
return spans === undefined ? carriedRun(range, context) : success({ segments: [syntax(spans)] })
}
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, { ...range, mark }, 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 })
@@ -383,24 +313,19 @@ function emitMarkedRun(nodes: readonly RunNode[], mark: AdfMark, depth: number,
return success({ segments: [syntax(spellInlineDirectiveOpener(mark.type)), ...inner.value.segments, syntax(`]${attributes}`)] })
}
function emitEmphasis(nodes: readonly RunNode[], spelling: string, depth: number, range: NodeRange, context: InlineContext): Result<Emission> {
const inner = emitRun(nodes, depth + 1, range.first, context)
function emitEmphasis(nodes: readonly RunNode[], spelling: string, depth: number, run: MarkRun, context: InlineContext): Result<Emission> {
const inner = emitRun(nodes, depth + 1, run.first, context)
if (!inner.ok) return inner
if (inner.value.carries !== undefined) return inner
const spelled = spellEdgeWhitespace(inner.value.segments)
const spelled = spellEdgeWhitespace(inner.value.segments, context.flavour)
return success({
segments: [
{ emphasis: 'open', escaping: 'none', nodes: { ...range, depth }, text: spelling },
...spelled,
{ emphasis: 'close', escaping: 'none', nodes: { ...range, depth }, text: spelling },
],
segments: [{ emphasis: 'open', escaping: 'none', nodes: run, text: spelling }, ...spelled, { emphasis: 'close', escaping: 'none', nodes: run, text: spelling }],
})
}
function emitHighlight(nodes: readonly RunNode[], depth: number, range: NodeRange, context: InlineContext): Result<Emission> {
const inner = emitRun(nodes, depth + 1, range.first, context)
function emitHighlight(nodes: readonly RunNode[], depth: number, run: MarkRun, context: InlineContext): Result<Emission> {
const inner = emitRun(nodes, depth + 1, run.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 }],
})
+68
View File
@@ -0,0 +1,68 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts'
import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts'
import { markSpelling } from '../mark-spellings.ts'
import { slotFault } from '../directive/syntax.ts'
export type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: RunNode[] } | { index: number; kind: 'plain'; node: AdfNode }
// `parted` keeps a run from crossing any node.
export type RunGrouping = { carried: ReadonlySet<number>; parted: boolean }
// An inline node inside the runs of its first `outer` marks. A node a run crosses holds no mark of that run, so its `outer` stays.
export type RunNode = { node: AdfNode; outer: number }
export const partedLine: RunGrouping = { carried: new Set(), parted: true }
export const spannedLine: RunGrouping = { carried: new Set(), parted: false }
// spec/flavour.md, Marks. The portable reducer must ask this too, or it trims edge whitespace by runs the writers do not spell.
export function inlineRuns(nodes: readonly RunNode[], firstIndex: number, grouping: RunGrouping): InlineRun[] {
const runs: InlineRun[] = []
let end = 0
for (const [offset, { node, outer }] of nodes.entries()) {
if (offset < end) continue
const index = firstIndex + offset
// A carried node spells its marks inside the carry.
const mark = carries(node, grouping.carried, index) ? undefined : nodeMarks(node)[outer]
if (mark === undefined) {
runs.push({ index, kind: 'plain', node })
continue
}
end = runEnd(nodes, offset, mark, firstIndex, grouping)
runs.push({ index, kind: 'marked', mark, nodes: nodes.slice(offset, end).map((runNode) => insideRun(runNode, mark) ?? runNode) })
}
return runs
}
// A run opens and closes at a node holding its mark, so a node it would cross past the last such node stays outside.
function runEnd(nodes: readonly RunNode[], first: number, mark: AdfMark, firstIndex: number, { carried, parted }: RunGrouping): number {
let end = first + 1
for (let offset = end; offset < nodes.length; offset += 1) {
const runNode = nodes[offset]
if (runNode === undefined || carries(runNode.node, carried, firstIndex + offset)) break
if (insideRun(runNode, mark) !== undefined) end = offset + 1
else if (parted || !runCrosses(runNode.node, mark)) break
}
return end
}
export function insideRun({ node, outer }: RunNode, mark: AdfMark): RunNode | undefined {
const held = nodeMarks(node)[outer]
return held !== undefined && identicalMark(held, mark) ? { node, outer: outer + 1 } : undefined
}
// The readers refuse `marks=empty` inside a mark's spelling.
function runCrosses(node: AdfNode, mark: AdfMark): boolean {
const kind = markSpelling(mark.type)?.kind
return (kind === 'emphasis' || kind === 'link') && !takesMark(node, mark.type) && node.marks?.length !== 0
}
// 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.
export 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
}
+2 -2
View File
@@ -1,3 +1,4 @@
import type { AdfMark } from '../../adf/document.ts'
import type { Claims, WrittenFlavour } from '../flavour.ts'
import type { InlineReading } from '../inline-content.ts'
import type { InlineSource, InlineToken } from '../inline-tokens.ts'
@@ -19,8 +20,7 @@ export type InlineEscaping = (typeof escapingCodes)[number]
export type NodeRange = { first: number; last: number }
// `depth` is the run's nesting level. On a parted line, the only line a fallback reads, that is the index into each node's marks array that the run spells.
export type MarkRun = NodeRange & { depth: number }
export type MarkRun = NodeRange & { mark: AdfMark }
export type InlineSegment =
| { emphasis: DelimiterRole; escaping: 'none'; highlight?: undefined; nodes: MarkRun; text: string }
+7 -8
View File
@@ -1,9 +1,9 @@
import type { AdfNode } from '../../adf/document.ts'
import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { DelimiterRole, MarkRun, PlacedSegment } from './line-escaping.ts'
import type { InlineReading } from '../inline-content.ts'
import { delimiterFlags, isWordCharacter } from '../commonmark/emphasis-matching.ts'
import { highlightFlanking } from '../inline-tokens.ts'
import { nodeMarks } from '../../adf/document.ts'
import { identicalMark, nodeMarks } from '../../adf/document.ts'
// How the caller takes a run back: a carry swaps its nodes for one carry directive, splitting every run holding it; a drop removes its mark from the line's `nodes`.
export type RunFallback = { kind: 'carry' } | { kind: 'drop'; nodes: readonly AdfNode[] }
@@ -88,14 +88,13 @@ function mergingEnds(pairs: readonly EmphasisPair[], fallback: RunFallback): Set
// Dropping the run merges no runs and moves no run's end, whatever else drops: at each end, the node outside holds no mark type the end node holds inside the run's mark.
function dropsAlone(nodes: readonly AdfNode[], run: MarkRun): boolean {
return endsAlone(nodes[run.first], nodes[run.first - 1], run.depth) && endsAlone(nodes[run.last], nodes[run.last + 1], run.depth)
return endsAlone(nodes[run.first], nodes[run.first - 1], run.mark) && endsAlone(nodes[run.last], nodes[run.last + 1], run.mark)
}
function endsAlone(edge: AdfNode | undefined, beside: AdfNode | undefined, depth: number): boolean {
const besideTypes = new Set(nodeMarks(beside ?? {}).map((mark) => mark.type))
return nodeMarks(edge ?? {})
.slice(depth + 1)
.every((mark) => !besideTypes.has(mark.type))
function endsAlone(edge: AdfNode | undefined, beside: AdfNode | undefined, mark: AdfMark): boolean {
const besideTypes = new Set(nodeMarks(beside ?? {}).map((held) => held.type))
const marks = nodeMarks(edge ?? {})
return marks.slice(marks.findIndex((held) => identicalMark(held, mark)) + 1).every((held) => !besideTypes.has(held.type))
}
// A pair falls with the first only where that leaves the line as the one-run-per-pass reference writes it: it falls cleanly, and cannot read whichever character it is written with.
+1 -17
View File
@@ -2,14 +2,13 @@ import type { AdfAttributes, AdfMark, AdfNode } from '../adf/document.ts'
import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { LinkDefinition, TitleEscapes } from './commonmark/link-syntax.ts'
import type { MarkType } from '../adf/mark-attributes.ts'
import { emptyKeys, identicalMark, nodeAttrs, nodeMarks } from '../adf/document.ts'
import { emptyKeys, nodeAttrs, nodeMarks } from '../adf/document.ts'
import { escapeUnbalanced, spellLinkTarget } from './commonmark/link-syntax.ts'
import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './directive/syntax.ts'
import { holdsEntityReference } from './commonmark/entity-references.ts'
import { isAutolink } from './commonmark/grammar.ts'
import { isMarkType, linkAttrs, linkMarkFrom, markAttributes, type LinkAttrs } from '../adf/mark-attributes.ts'
import { spellEmptyKeys } from './directive/empty-keys.ts'
import { takesMark } from '../adf/inline-nodes.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string }
@@ -18,9 +17,6 @@ export type MarkSpelling = Spelling & { attributes: AttributeVocabulary }
export type CommonMarkLink = { form: 'autolink'; target?: undefined } | { form: 'inline'; target: string }
// An inline node inside the runs of its first `outer` marks.
export type RunNode = { node: AdfNode; outer: number }
const markSpellings: Readonly<Record<MarkType, Spelling>> = {
border: { kind: 'directive' },
code: { kind: 'code' },
@@ -45,18 +41,6 @@ export function isEmphasis(mark: AdfMark): boolean {
return markSpelling(mark.type)?.kind === 'emphasis'
}
// spec/flavour.md, Marks.
export function insideRun({ node, outer }: RunNode, mark: AdfMark): RunNode | undefined {
const held = nodeMarks(node)[outer]
return held !== undefined && identicalMark(held, mark) ? { node, outer: outer + 1 } : undefined
}
// spec/flavour.md, Marks. The readers refuse `marks=empty` inside a mark's spelling.
export function runSpans(node: AdfNode, mark: AdfMark): boolean {
const kind = markSpelling(mark.type)?.kind
return (kind === 'emphasis' || kind === 'link') && !takesMark(node, mark.type) && node.marks?.length !== 0
}
export function linkMark(definition: LinkDefinition): AdfMark {
return linkMarkFrom(toLinkAttrs(definition))
}
@@ -5,6 +5,7 @@ import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/doc
import { adfToPortableMarkdown, reduceToPortable } from './adf-to-portable-markdown.ts'
import { largestNesting } from '../../nesting.ts'
import { portableLineFallback } from '../emit/inline-line.ts'
import { spannedLine } from '../emit/inline-runs.ts'
import { portableMarkdownToAdf } from './portable-markdown-to-adf.ts'
const code: AdfMark = { type: 'code' }
@@ -411,13 +412,13 @@ test('drops every em, strike and strong mark, and keeps every other, in a line s
test('gives up every highlight run whose own delimiter cannot flank in one fallback, so a line of them re-emits once however many it holds', () => {
const highlight: AdfMark = { type: 'backgroundColor' }
const line = [text('a '), text('x', highlight), text('a '), text('x', highlight), text('a '), text('x', highlight), text('a')]
const runs = [{ depth: 0, first: 1, last: 1 }, { depth: 0, first: 3, last: 3 }, { depth: 0, first: 5, last: 5 }]
assert.deepEqual(portableLineFallback(line, 'paragraph', []), { ok: true, value: { kind: 'unspellable-run', runs } })
const runs = [{ first: 1, last: 1, mark: highlight }, { first: 3, last: 3, mark: highlight }, { first: 5, last: 5, mark: highlight }]
assert.deepEqual(portableLineFallback(line, 'paragraph', [], spannedLine), { ok: true, value: { kind: 'unspellable-run', runs } })
})
test('refuses a portable line holding a node only the carry spells, at the line', () => {
for (const mark of [{ attrs: { a: 1 }, type: 'em' }, { type: 'odd' }]) {
const refused = portableLineFallback([text('x', mark)], 'paragraph', ['content', 0])
const refused = portableLineFallback([text('x', mark)], 'paragraph', ['content', 0], spannedLine)
assert.deepEqual(refused.ok ? refused : [refused.error.code, refused.error.path], ['unsupported-node-shape', ['content', 0]])
}
})
@@ -427,8 +428,15 @@ test('spells an emphasis once across the code spans and hard breaks between two
assert.equal(portable(paragraph(text('One', em), { type: 'hardBreak' }, text('Two', em))), '_One\\\nTwo_\n')
assert.equal(portable(paragraph(text('a', link('/u')), { type: 'hardBreak' }, text('b', link('/u')))), '[a\\\nb](/u)\n')
assert.equal(portable(paragraph(text('Ping ', strong), { attrs: { id: 'x', text: '@A' }, type: 'mention' }, text(' now', strong))), '**Ping** @A **now**\n')
assert.equal(portable(paragraph(text('a', link('/u')), { type: 'hardBreak' }, text(' b', link('/u')))), '[a](/u)\\\n[ b](/u)\n')
assert.equal(portable(paragraph(text('a\n b', link('/u')))), '[a](/u)\\\n[ b](/u)\n')
assert.equal(portable(paragraph(text('a ', link('/u')), { type: 'hardBreak' }, text(' b', link('/u')))), '[a\\\nb](/u)\n')
assert.equal(portable(paragraph(text('a\n b', link('/u')))), '[a\\\nb](/u)\n')
assert.equal(portable(paragraph(text('a ', link('/u')), { type: 'hardBreak' }, text('b'))), '[a ](/u)\\\nb\n')
})
test('writes a line whose spanned spelling misreads as the parted line, whitespace outside its runs and no directive', () => {
assert.equal(portable(paragraph(text('a', strong), text('b ', em, strong), text('a', code), text('a', em), text('a'))), '**a**_**b**_ `a`*a*a\n')
const strike: AdfMark = { type: 'strike' }
assert.equal(portable(paragraph(text('a', strong, strike), text('a', code), text(' c', strong, strike), text('b '))), '**~~a~~**`a` ~~c~~b\n')
})
test('breaks a line at a newline and trims whitespace at every edge CommonMark strips', () => {
+11 -11
View File
@@ -6,9 +6,8 @@ import type { AdfNode } from '../../adf/document.ts'
import type { LineContainer } from '../line-container.ts'
import type { PortableLineFallback } from '../emit/inline-line.ts'
import { lineContainer, markedLine, propertyRuns } from '../../conformance/property-harness.ts'
import { nodeMarks } from '../../adf/document.ts'
import { portableLeaves, reduceInline, trimmedEdges, withoutFallback } from './inline-reduction.ts'
import { portableLineFallback } from '../emit/inline-line.ts'
import { judgedLine, portableLeaves, reduceInline, trimmedEdges, withoutFallback } from './inline-reduction.ts'
import { spannedLine } from '../emit/inline-runs.ts'
import { success, type Result } from '../../result.ts'
test('reduces a line alike dropping every run a pass may or the first alone', () => {
@@ -56,7 +55,7 @@ test('takes code off every node of an opening link\'s run that an opening-link f
})
test('refuses an unspellable-run fallback that takes no mark', () => {
const refused = withoutFallback([{ marks: [{ type: 'em' }], text: 'a', type: 'text' }], { kind: 'unspellable-run', runs: [{ depth: 1, first: 0, last: 0 }] }, [])
const refused = withoutFallback([{ marks: [{ type: 'em' }], text: 'a', type: 'text' }], { kind: 'unspellable-run', runs: [{ first: 0, last: 0, mark: { type: 'strike' } }] }, [])
assert.equal(refused.ok ? 'kept' : refused.error.code, 'unsupported-node-shape')
})
@@ -65,17 +64,18 @@ function firstRunLine(nodes: readonly AdfNode[], container: LineContainer): Resu
const leaves = portableLeaves(nodes, container, [], 0)
if (!leaves.ok) return leaves
for (let current = leaves.value; ; ) {
const fallback = portableLineFallback(current, container, [])
if (!fallback.ok) return fallback
if (fallback.value === undefined) return success(current)
const fixed = withoutFallback(current, firstRun(current, fallback.value), [])
const judged = judgedLine(current, container, [])
if (!judged.ok) return judged
const { fallback, leaves: line } = judged.value
if (fallback === undefined) return success(line)
const fixed = withoutFallback(line, firstRun(fallback), [])
if (!fixed.ok) return fixed
current = trimmedEdges(fixed.value)
current = trimmedEdges(fixed.value, spannedLine)
}
}
function firstRun(leaves: readonly AdfNode[], fallback: PortableLineFallback): PortableLineFallback {
function firstRun(fallback: PortableLineFallback): PortableLineFallback {
if (fallback.kind === 'opening-link') return fallback
const [first, ...others] = fallback.runs
return { kind: 'unspellable-run', runs: [first, ...others.filter((run) => nodeMarks(leaves[run.first] ?? {})[run.depth]?.type === 'backgroundColor')] }
return { kind: 'unspellable-run', runs: [first, ...others.filter((run) => run.mark.type === 'backgroundColor')] }
}
+65 -28
View File
@@ -1,17 +1,25 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { LineContainer } from '../line-container.ts'
import type { MarkRun } from '../emit/line-escaping.ts'
import type { RunGrouping, RunNode } from '../emit/inline-runs.ts'
import { codeTextMarkTypes } from '../../adf/inline-nodes.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { identicalMark, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { imageAside } from '../image-aside.ts'
import { insideRun, isEmphasis } from '../mark-spellings.ts'
import { inlineRuns, insideRun, partedLine, spannedLine } from '../emit/inline-runs.ts'
import { isEmphasis } from '../mark-spellings.ts'
import { joinsWhenEditorNormal } from './editor-normal.ts'
import { largestNesting } from '../../nesting.ts'
import { partsShownText, referenceName, shownImage, shownLink, shownText, shownUrl } from '../../adf/shown-text.ts'
import { portableLineFallback, readBackPasses, runsSharedWithNext, type PortableLineFallback } from '../emit/inline-line.ts'
import { portableLineFallback, readBackPasses, type PortableLineFallback } from '../emit/inline-line.ts'
import { spellDestination } from '../commonmark/link-syntax.ts'
// What stands beside a leaf's edge: whether a line edge does, and how many runs hold the leaf and its neighbour both.
type Beside = { lineEdge: boolean; sharedRuns: number }
// `leaves` is the line `fallback` was judged on.
type JudgedLine = { fallback: PortableLineFallback | undefined; leaves: AdfNode[] }
const highlight = 'backgroundColor'
const highlightMark: AdfMark = { type: highlight }
const edgeStrippingMarks: readonly string[] = [highlight, 'em', 'strike', 'strong']
@@ -26,7 +34,7 @@ export function reduceInline(nodes: readonly AdfNode[], container: LineContainer
export function portableLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
const leaves = inlineLeaves(nodes, container, path, depth)
if (!leaves.ok) return leaves
return success(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value))))
return success(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value, spannedLine)), spannedLine))
}
export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
@@ -161,15 +169,15 @@ function withMarks(leaf: AdfNode, marks: readonly AdfMark[]): AdfNode {
return marks.length === 0 ? unmarked : { ...unmarked, marks: [...marks] }
}
export function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] {
export function trimmedEdges(leaves: readonly AdfNode[], grouping: RunGrouping): AdfNode[] {
// Terminates: a pass that changes a leaf drops whitespace from the leaf's edge or takes a mark off that whitespace, and merging adds no whitespace or mark.
for (let current = leaves; ; ) {
const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsWhenEditorNormal))
const shared = runsSharedWithNext(merged)
const shared = runsSharedWithNext(merged, grouping)
let changed = false
const trimmed: AdfNode[] = []
for (const [index, leaf] of merged.entries()) {
const edges = leafEdges(leaf, runsBeside(merged[index - 1], shared[index - 1]), runsBeside(merged[index + 1], shared[index]))
const edges = leafEdges(leaf, beside(merged[index - 1], shared[index - 1]), beside(merged[index + 1], shared[index]))
if (edges === undefined) {
trimmed.push(leaf)
continue
@@ -190,20 +198,35 @@ function withoutEdgeBreaks(leaves: readonly AdfNode[]): AdfNode[] {
return leaves.slice(first, last + 1)
}
// How many runs a leaf shares with its neighbour, `undefined` at a line edge.
function runsBeside(neighbour: AdfNode | undefined, shared: number | undefined): number | undefined {
return neighbour === undefined || neighbour.type === 'hardBreak' ? undefined : shared
// For each leaf but the last, how many runs of the line hold both it and the next leaf.
function runsSharedWithNext(leaves: readonly AdfNode[], grouping: RunGrouping): number[] {
const shared = leaves.map(() => 0)
countSharedRuns(leaves.map((node) => ({ node, outer: 0 })), 0, grouping, shared)
return shared
}
// Recurses once per mark, and a portable leaf holds one mark of each kept type.
function countSharedRuns(nodes: readonly RunNode[], firstIndex: number, grouping: RunGrouping, shared: number[]): void {
for (const run of inlineRuns(nodes, firstIndex, grouping)) {
if (run.kind === 'plain') continue
for (let index = run.index; index < run.index + run.nodes.length - 1; index += 1) shared[index] = (shared[index] ?? 0) + 1
countSharedRuns(run.nodes, run.index, grouping, shared)
}
}
function beside(neighbour: AdfNode | undefined, sharedRuns = 0): Beside {
return { lineEdge: neighbour === undefined || neighbour.type === 'hardBreak', sharedRuns }
}
// spec/flavour.md, Inline nodes: edge whitespace leaves every stripping mark opening or closing beside it, and goes at a line edge.
function leafEdges(leaf: AdfNode, runsBefore: number | undefined, runsAfter: number | undefined): AdfNode[] | undefined {
function leafEdges(leaf: AdfNode, before: Beside, after: Beside): AdfNode[] | undefined {
const marks = nodeMarks(leaf)
const text = leaf.text
if (text === undefined || marks.some((mark) => mark.type === 'code')) return undefined
const lead = text.slice(0, text.search(/[^ \t]|$/))
const trail = text.slice(text.search(/[ \t]*$/))
const leadDepth = edgeDepth(marks, runsBefore, lead)
const trailDepth = edgeDepth(marks, runsAfter, trail)
const leadDepth = edgeDepth(marks, before, lead)
const trailDepth = edgeDepth(marks, after, trail)
if (leadDepth === marks.length && trailDepth === marks.length) return undefined
if (lead === text) return leadDepth === undefined || trailDepth === undefined ? [] : [textLeaf(text, marks.slice(0, Math.min(leadDepth, trailDepth)))]
const edges: AdfNode[] = []
@@ -214,26 +237,40 @@ function leafEdges(leaf: AdfNode, runsBefore: number | undefined, runsAfter: num
return edges
}
// How many of the leaf's marks the edge whitespace keeps, `undefined` where it drops at a line edge.
function edgeDepth(marks: readonly AdfMark[], sharedRuns: number | undefined, whitespace: string): number | undefined {
// How many of the leaf's marks the edge whitespace keeps, `undefined` where it drops at a line edge. Text not marked `code` holds every run over it, so the runs it shares are its outermost marks.
function edgeDepth(marks: readonly AdfMark[], { lineEdge, sharedRuns }: Beside, whitespace: string): number | undefined {
if (whitespace === '') return marks.length
const stripping = marks.findIndex((mark, index) => index >= (sharedRuns ?? 0) && edgeStrippingMarks.includes(mark.type))
// A run crossing a hard break holds this whitespace at the edge of a line, where CommonMark strips it.
if (lineEdge && sharedRuns > 0) return undefined
const stripping = marks.findIndex((mark, index) => index >= sharedRuns && edgeStrippingMarks.includes(mark.type))
const kept = stripping === -1 ? marks.length : stripping
return kept === 0 && sharedRuns === undefined ? undefined : kept
return kept === 0 && lineEdge ? undefined : kept
}
function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<AdfNode[]> {
// Terminates: withoutFallback refuses a pass that takes no mark, and trimmedEdges adds none to any character.
for (let current = leaves, pass = 1; ; pass += 1) {
const fallback = portableLineFallback(current, container, path)
if (!fallback.ok) return fallback
if (fallback.value === undefined) return success(current)
const fixed = withoutFallback(current, fallback.value, path)
const judged = judgedLine(current, container, path)
if (!judged.ok) return judged
const { fallback, leaves: line } = judged.value
if (fallback === undefined) return success(line)
const fixed = withoutFallback(line, fallback, path)
if (!fixed.ok) return fixed
current = trimmedEdges(pass === readBackPasses ? withoutEmphasis(fixed.value) : fixed.value)
current = trimmedEdges(pass === readBackPasses ? withoutEmphasis(fixed.value) : fixed.value, spannedLine)
}
}
// spec/flavour.md, Marks: the lines in the order the writer tries them, each trimmed by its own runs.
export function judgedLine(spanned: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<JudgedLine> {
const spannedFallback = portableLineFallback(spanned, container, path, spannedLine)
if (!spannedFallback.ok) return spannedFallback
if (spannedFallback.value === undefined) return success({ fallback: undefined, leaves: [...spanned] })
const parted = trimmedEdges(spanned, partedLine)
const partedFallback = portableLineFallback(parted, container, path, partedLine)
if (!partedFallback.ok) return partedFallback
return success({ fallback: partedFallback.value, leaves: parted })
}
export function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLineFallback, path: ConvertErrorPath): Result<AdfNode[]> {
const fixed = fallback.kind === 'unspellable-run' ? withoutMarks(leaves, fallback.runs) : withoutOpeningLinkCode(leaves)
return fixed === undefined ? failure('unsupported-node-shape', 'a portable line keeps a spelling that dropping a mark does not change', path) : success(fixed)
@@ -253,19 +290,19 @@ function withoutEmphasis(leaves: readonly AdfNode[]): AdfNode[] {
return leaves.map((leaf) => withMarks(leaf, nodeMarks(leaf).filter((mark) => !isEmphasis(mark))))
}
// The runs one pass takes back may nest, so a leaf drops its mark at each depth a run over it names.
// The runs one pass takes back may nest, so a leaf drops the mark of each run over it.
function withoutMarks(leaves: readonly AdfNode[], runs: readonly MarkRun[]): AdfNode[] | undefined {
const depths = new Map<number, number[]>()
const droppedAt = new Map<number, AdfMark[]>()
for (const run of runs) {
for (let index = run.first; index <= run.last; index += 1) {
const held = depths.get(index)
if (held === undefined) depths.set(index, [run.depth])
else held.push(run.depth)
const dropped = droppedAt.get(index)
if (dropped === undefined) droppedAt.set(index, [run.mark])
else dropped.push(run.mark)
}
}
const kept = leaves.map((leaf, index) => {
const dropped = depths.get(index)
return dropped === undefined ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => !dropped.includes(held)))
const dropped = droppedAt.get(index)
return dropped === undefined ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => !dropped.some((mark) => identicalMark(held, mark))))
})
return markCount(kept) < markCount(leaves) ? kept : undefined
}