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:
+3
-2
@@ -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  tonight](/moon)` reads as
|
||||
|
||||
@@ -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
@@ -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
@@ -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' })
|
||||
|
||||
@@ -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 }],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')] }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user