diff --git a/src/adf/document.ts b/src/adf/document.ts
index 161a4d3..446cb41 100644
--- a/src/adf/document.ts
+++ b/src/adf/document.ts
@@ -109,14 +109,19 @@ function heldTextLength(nodes: readonly AdfNode[]): number {
}
export function mergeAdjacentText(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): AdfNode[] {
- const merged: AdfNode[] = []
- for (const node of nodes) {
+ return mergedTextRuns(nodes, joins).map(({ node }) => node)
+}
+
+// `first`: the index of the first of the nodes merged into `node`.
+export function mergedTextRuns(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): { first: number; node: AdfNode }[] {
+ const merged: { first: number; node: AdfNode }[] = []
+ for (const [index, node] of nodes.entries()) {
const previous = merged[merged.length - 1]
- if (previous !== undefined && joins(previous, node)) {
- merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
+ if (previous !== undefined && joins(previous.node, node)) {
+ previous.node = { ...previous.node, text: `${previous.node.text ?? ''}${node.text ?? ''}` }
continue
}
- merged.push(node)
+ merged.push({ first: index, node })
}
return merged
}
diff --git a/src/html/carry-content.ts b/src/html/carry-content.ts
index 1207505..e58cd3e 100644
--- a/src/html/carry-content.ts
+++ b/src/html/carry-content.ts
@@ -6,35 +6,31 @@ import { nodeAttrs, nodeContent } from '../adf/document.ts'
import { partsShownText, referenceName, shownImage, shownLink, shownText, shownUrl } from '../adf/shown-text.ts'
import { shownValue } from './escaping.ts'
-// The content a carry element shows of one block; `code` the code block holding it, if any.
-type ShownBlock = { code: AdfNode | undefined; html: string }
+// One object per block a node stands in, so a block's content after a nested block parts from its content before; `code` the code block it is, if any.
+type BlockPart = { code: AdfNode | undefined }
+
+type ShownBlock = { html: string; part: BlockPart }
// What a carry element shows of the node it holds, and all `htmlToAdf` accepts there, so a change here breaks reading stored HTML (docs/decisions.md §The formats are API).
export function carryContent(node: AdfNode, tag: 'div' | 'span'): string {
if (tag === 'span') return shownBlocks(node, '
').map(({ html }) => html).join('
')
- return shownBlocks(node, '\n').map(({ code, html }) => (code === undefined ? `
${html}
` : `${html}
`)).join('')
+ return shownBlocks(node, '\n').map(({ html, part: { code } }) => (code === undefined ? `${html}
` : `${html}
`)).join('')
}
// What a carry element shows, block by block, as the writer panels picked it (docs/decisions.md §The HTML dialect).
function shownBlocks(root: AdfNode, codeLineFeed: string): ShownBlock[] {
const shown: ShownBlock[] = []
- let current: ShownBlock = { code: undefined, html: '' }
- // Each entry holds the code block it stands in, none where another block stands between them; a `null` node ends a block, and what follows stands in that entry's `code`, the code block around the block that ended.
- const pending: { code: AdfNode | undefined; node: AdfNode | null }[] = [{ code: undefined, node: root }]
+ const pending: { node: AdfNode; part: BlockPart }[] = [{ node: root, part: { code: undefined } }]
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
const { node } = next
- const code = node?.type === 'codeBlock' ? node : node !== null && partsShownText(node.type) ? undefined : next.code
- if (node === null || partsShownText(node.type)) {
- if (current.html !== '') shown.push(current)
- current = { code, html: '' }
- }
- if (node === null) continue
- current = { ...current, html: current.html + shownPiece(node, current.code === undefined ? '
' : codeLineFeed) }
- if (partsShownText(node.type)) pending.push({ code: next.code, node: null })
- for (const child of [...nodeContent(node)].reverse()) pending.push({ code, node: child })
+ const part = partsShownText(node.type) ? { code: node.type === 'codeBlock' ? node : undefined } : next.part
+ const piece = shownPiece(node, part.code === undefined ? '
' : codeLineFeed)
+ const last = shown.at(-1)
+ if (last?.part === part) last.html += piece
+ else shown.push({ html: piece, part })
+ for (const child of [...nodeContent(node)].reverse()) pending.push({ node: child, part })
}
- if (current.html !== '') shown.push(current)
- return shown
+ return shown.filter(({ html }) => html !== '')
}
// The node's own part of what its carry element shows.
diff --git a/src/html/data-attributes.ts b/src/html/data-attributes.ts
index 7e4e51d..1d40240 100644
--- a/src/html/data-attributes.ts
+++ b/src/html/data-attributes.ts
@@ -22,13 +22,13 @@ export function dataAttributes(node: AdfNode, vocabulary: AttributeVocabulary, w
return attributes
}
-// What `dataAttributes` wrote, its pairs' values unescaped; the caller reads `leading`, an attribute the element writes ahead of them, where it stands first. A pair it does not write in that order is no HTML of the dialect; a name or value no attribute of the node spells is the node's shape, as a directive's is (docs/decisions.md §Which code a cause takes).
-export function readDataAttributes(pairs: readonly (readonly [string, string])[], element: string, vocabulary: AttributeVocabulary, written: readonly string[], leading?: string): Read {
+// What `dataAttributes` wrote, its pairs' values unescaped. A pair it does not write in that order is no HTML of the dialect; a name or value no attribute of the node spells is the node's shape, as a directive's is (docs/decisions.md §Which code a cause takes).
+export function readDataAttributes(pairs: readonly (readonly [string, string])[], element: string, vocabulary: AttributeVocabulary, written: readonly string[]): Read {
const attrs: AdfAttributes = {}
let previous = ''
- for (const [name, spelled] of leading !== undefined && pairs[0]?.[0] === leading ? pairs.slice(1) : pairs) {
+ for (const [name, spelled] of pairs) {
const [key, kind] = dataKey(name, vocabulary, written) ?? []
- if (key === undefined || kind === undefined) return { fault: nameFault(name, element, leading, previous) }
+ if (key === undefined || kind === undefined) return { fault: nameFault(name, element) }
if (key <= previous) return { fault: unmappable(`the data-* attributes stand once each, sorted by the attribute they name: found ${name} after ${dataName(previous)}`) }
const value = dataValue(spelled, kind)
if (value === undefined) return { fault: { code: 'unsupported-node-shape', message: `${name} holds no ${kind} written as compact JSON` } }
@@ -38,6 +38,15 @@ export function readDataAttributes(pairs: readonly (readonly [string, string])[]
return { value: attrs }
}
+// As `readDataAttributes`, past `leading`, an attribute the element writes ahead of them, where it stands first; the caller reads `leading`.
+export function readDataAttributesAfter(leading: string, pairs: readonly (readonly [string, string])[], element: string, vocabulary: AttributeVocabulary, written: readonly string[]): Read {
+ const repeated = pairs.findIndex(([name], index) => index > 0 && name === leading)
+ const data = readDataAttributes(pairs.slice(pairs[0]?.[0] === leading ? 1 : 0, repeated < 0 ? pairs.length : repeated), element, vocabulary, written)
+ const before = repeated < 0 ? undefined : pairs[repeated - 1]
+ if (data.fault !== undefined || before === undefined) return data
+ return { fault: unmappable(`<${element}> takes ${leading} once, before its data-* attributes: found ${leading} after ${before[0]}`) }
+}
+
// The attribute `name` spells, among those the element itself does not write.
function dataKey(name: string, vocabulary: AttributeVocabulary, written: readonly string[]): [string, AttributeKind] | undefined {
return Object.entries(vocabulary).find(([candidate]) => !written.includes(candidate) && dataName(candidate) === name)
@@ -53,11 +62,9 @@ export function dataValue(spelled: string, kind: AttributeKind): JsonValue | und
return value !== undefined && holdsKind(value, kind) && spellsCanonicalJson(value, 'compact', spelled) ? value : undefined
}
-// At a `leading`, `previous` is empty only where another `leading` stood first.
-function nameFault(name: string, element: string, leading: string | undefined, previous: string): ConvertFault {
- if (name.startsWith('data-')) return { code: 'unsupported-node-shape', message: `<${element}> takes no ${name} attribute` }
- if (name !== leading) return unmappable(`<${element}> takes no ${name} attribute`)
- return unmappable(`<${element}> takes ${name} once, before its data-* attributes: found ${name} after ${previous === '' ? name : dataName(previous)}`)
+function nameFault(name: string, element: string): ConvertFault {
+ const message = `<${element}> takes no ${name} attribute`
+ return name.startsWith('data-') ? { code: 'unsupported-node-shape', message } : unmappable(message)
}
function unmappable(message: string): ConvertFault {
diff --git a/src/html/parse/html-to-adf.ts b/src/html/parse/html-to-adf.ts
index c506b12..0d31451 100644
--- a/src/html/parse/html-to-adf.ts
+++ b/src/html/parse/html-to-adf.ts
@@ -18,7 +18,7 @@ import { positioned, success } from '../../result.ts'
import { readAnchorAttributes } from '../anchor-element.ts'
import { readCarry } from './carry-element.ts'
import { readCarryAttributes } from '../carry-attributes.ts'
-import { readDataAttributes } from '../data-attributes.ts'
+import { readDataAttributes, readDataAttributesAfter } from '../data-attributes.ts'
import { readImageAttributes } from '../image-element.ts'
import { readLanguageClass } from '../language-class.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts'
@@ -242,7 +242,8 @@ function readContainer(html: string, tag: StartTag, type: ContainerType, place:
const at = tag.start + `<${tag.name} start="`.length
return refused('unmappable-html', `an ordered list starts at an integer, written as JavaScript writes it: ${foundAt(html, at)}`, html, at, place)
}
- const data = readDataAttributes(tag.attributes, tag.name, blockNodes[type].attributes, ordered ? ['order'] : [], ordered ? 'start' : undefined)
+ const vocabulary = blockNodes[type].attributes
+ const data = ordered ? readDataAttributesAfter('start', tag.attributes, tag.name, vocabulary, ['order']) : readDataAttributes(tag.attributes, tag.name, vocabulary, [])
if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place)
const node = built(type, order === undefined ? data.value : { order, ...data.value }, [])
const closer = `${tag.name}>`
@@ -320,9 +321,7 @@ function readItemLine(html: string, index: number, element: string, open: Open):
if (!line.ok) return line
const { end, node } = line.value
if (!html.startsWith(closer, end) && !html.startsWith('\n', end)) {
- const lineEnd = `the line ends at ${closer}, or at a line feed before the next block`
- // After a line's ``, or before a block or the next item, no inline element can stand.
- const stop = line.value.form === 'bare' && !isBlockStart(html, end) && startTagName(html, end) !== element ? misread(html, end, `${lineEnd}, and its text spells < as <`, 'no inline element of the dialect') : { at: end, message: `${lineEnd}: ${foundAt(html, end)}` }
+ const stop = lineEndRefusal(html, end, line.value.form, element)
return refused('unmappable-html', stop.message, html, stop.at, open.place)
}
const paragraph = asFirstParagraph({ end, node }, line.value.form, html, index, place)
@@ -331,6 +330,13 @@ function readItemLine(html: string, index: number, element: string, open: Open):
return success({ end: end + 1, kind: 'opened', open: { ...open, content: [node] } })
}
+function lineEndRefusal(html: string, end: number, form: FirstParagraphForm, element: string): { at: number; message: string } {
+ const lineEnd = `the line ends at ${element}>, or at a line feed before the next block`
+ // After a line's ``, or before a block or the next item, no inline element can stand.
+ if (form !== 'bare' || isBlockStart(html, end) || startTagName(html, end) === element) return { at: end, message: `${lineEnd}: ${foundAt(html, end)}` }
+ return misread(html, end, `${lineEnd}, and its text spells < as <`, 'no inline element of the dialect')
+}
+
// The paragraph on an item's or cell's line and the form it stands in there, up to its closer or to the line feed before its next block.
function readLineParagraph(html: string, index: number, place: Place): Result<{ end: number; form: FirstParagraphForm; node: AdfNode }, ParseError> {
const tag = readStartTag(html, index)
diff --git a/src/html/parse/node-element.ts b/src/html/parse/node-element.ts
index 8cafaa6..41a5e8b 100644
--- a/src/html/parse/node-element.ts
+++ b/src/html/parse/node-element.ts
@@ -1,7 +1,7 @@
import type { AdfNode } from '../../adf/document.ts'
import { foundAt, misspelledText, refused, type Place, type ReadNode, type StartTag } from './source.ts'
import { nodeTypeOfElement } from '../node-element.ts'
-import { readDataAttributes } from '../data-attributes.ts'
+import { readDataAttributes, readDataAttributesAfter } from '../data-attributes.ts'
import { success } from '../../result.ts'
import { misspelledAt, unescapedValue } from '../escaping.ts'
@@ -13,7 +13,8 @@ export function readNodeElement(html: string, tag: StartTag, place: Place): Read
const [type, model] = found
const name = `<${tag.name} class="${model.class}">`
if (model.leading !== undefined && rest[0]?.[0] !== model.leading.name) return refused('unmappable-html', `${name} holds ${model.leading.name} first, before its data-* attributes: ${foundAt(html, tag.start)}`, html, tag.start, place)
- const data = readDataAttributes(rest, `${tag.name} class="${model.class}"`, model.vocabulary, model.written, model.leading?.name)
+ const element = `${tag.name} class="${model.class}"`
+ const data = model.leading === undefined ? readDataAttributes(rest, element, model.vocabulary, model.written) : readDataAttributesAfter(model.leading.name, rest, element, model.vocabulary, model.written)
if (data.fault !== undefined) return refused(data.fault.code, data.fault.message, html, tag.start, place)
const closer = `${tag.name}>`
const stop = html.indexOf('<', tag.end)
diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts
index a6dc213..2e50a5c 100644
--- a/src/markdown/emit/adf-to-markdown.ts
+++ b/src/markdown/emit/adf-to-markdown.ts
@@ -34,8 +34,9 @@ export type PlacedBlock = SpelledBlock & { node: AdfNode }
export type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
// CommonMark starts a new list where the marker changes, so every second list in a run of one kind takes `alternateText`.
type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: BlockText } | { alternateText: BlockText; interruptsParagraph: boolean; ordered: boolean; spelling: 'list'; text: BlockText }
-// Keyed by reference: only a caller building one object per position (the parse, the portable reduction) passes one; a consumer's document may share a node.
-export type SpellingMemo = Map
+type Spell = () => Result | undefined
+// ask: spell the node, keeping the spelling unless refused; take: spell the node, reading and dropping what is kept.
+export type SpellingMemo = Readonly Result | undefined>>
type WalkedItem = { node: AdfNode; walk: PlacedBlocks }
export type WrittenAs = { flavour: 'lossless' } | { blocks: FlavourBlockWriters; flavour: 'portable' }
export type Writing = WrittenAs & { memo: SpellingMemo | undefined }
@@ -110,14 +111,14 @@ function blankLineBetween(previous: PlacedBlock, next: PlacedBlock, container: B
export function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result {
const model = blockNodeModel(node.type)
if (model === undefined) return carriedFence(carriedBlock(node, path, depth))
- const readable = readableBlock(node, path, depth, writing, 'walked')
+ const readable = readableBlock(node, path, depth, writing)
if (readable !== undefined) return readable
return emitDirectiveBlock(node, model, path, depth, () => walkBlocks(nodeContent(node), path, depth + 1, writing))
}
// Whether the node takes a CommonMark spelling, rather than its directive.
export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing & { memo: SpellingMemo }): Result {
- const readable = readableBlock(node, path, depth, writing, 'asked')
+ const readable = writing.memo.ask(node, depth, () => spellReadableBlock(node, path, depth, writing))
if (readable === undefined) return success(false)
if (!readable.ok) return readable
const fits = withinLongest(readable.value.text.length, readable.value)
@@ -125,20 +126,30 @@ export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth:
return success(readable.value.spelling !== 'directive')
}
-// Only an `asked` call keeps a spelling, and any read drops it: the ancestor that reads it spells it into its own text, so no two levels hold that text.
-function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing, caller: 'asked' | 'walked'): Result | undefined {
- const { memo } = writing
- const kept = memo?.get(node)
- memo?.delete(node)
- if (kept !== undefined) {
- if (kept.block === undefined) return undefined
+function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result | undefined {
+ const spell = () => spellReadableBlock(node, path, depth, writing)
+ return writing.memo === undefined ? spell() : writing.memo.take(node, depth, spell)
+}
+
+// Keyed by reference: only a caller building one object per position (the parse, the portable reduction) makes one; a consumer's document may share a node.
+export function spellingMemo(): SpellingMemo {
+ const kept = new Map()
+ // Any read drops the entry: the ancestor that reads it spells it into its own text, so no two levels hold that text.
+ const take: SpellingMemo['take'] = (node, depth, spell) => {
+ const entry = kept.get(node)
+ kept.delete(node)
+ if (entry === undefined) return spell()
+ if (entry.block === undefined) return undefined
// An entry holds the spelling at its fill depth. A read at or above it rebases headroom; a read below re-spells, because a hit skips the walk's depth guards.
- if (depth <= kept.depth) return success({ ...kept.block, headroom: kept.block.headroom + kept.depth - depth })
+ return depth <= entry.depth ? success({ ...entry.block, headroom: entry.block.headroom + entry.depth - depth }) : spell()
}
- const spelled = spellReadableBlock(node, path, depth, writing)
- if (caller === 'asked' && spelled === undefined) memo?.set(node, { block: undefined, depth })
- if (caller === 'asked' && spelled?.ok === true) memo?.set(node, { block: spelled.value, depth })
- return spelled
+ const ask: SpellingMemo['ask'] = (node, depth, spell) => take(node, depth, () => {
+ const spelled = spell()
+ if (spelled === undefined) kept.set(node, { block: undefined, depth })
+ else if (spelled.ok) kept.set(node, { block: spelled.value, depth })
+ return spelled
+ })
+ return { ask, take }
}
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result | undefined {
diff --git a/src/markdown/emit/block-text.ts b/src/markdown/emit/block-text.ts
index 26e9e52..6317dfb 100644
--- a/src/markdown/emit/block-text.ts
+++ b/src/markdown/emit/block-text.ts
@@ -5,7 +5,7 @@ export type BlockText = Readonly
export const emptyLine = blockText('')
export function blockText(text: string): BlockText {
- const [first = '', ...rest] = text.split('\n')
- const empty = rest.filter((line) => line === '').length
- const later = { blank: rest.filter((line) => line !== '' && isBlankLine(line)).length, empty, written: rest.length - empty }
- return { first: { blank: first !== '' && isBlankLine(first), character: first.charAt(0), ruleCharacters: holdsOnlyRuleCharacters(first), text: first }, kind: 'text', later, length: text.length, lines: [first, ...rest] }
+ return measured({ kind: 'text', lines: text.split('\n') })
}
export function joinedText(parts: readonly BlockText[]): BlockText {
const [first, ...others] = parts
- if (first === undefined) return emptyLine
- let { blank, empty, written } = first.later
- let { length } = first
- for (const part of others) {
- blank += part.later.blank + (part.first.blank ? 1 : 0)
- empty += part.later.empty + (part.first.text === '' ? 1 : 0)
- written += part.later.written + (part.first.text === '' ? 0 : 1)
- length += 1 + part.length
- }
- return { first: first.first, kind: 'joined', later: { blank, empty, written }, length, parts }
+ return first === undefined ? emptyLine : measured({ kind: 'joined', parts: [first, ...others] })
}
// head, never blank, leads the first line; indent, spaces alone, leads every later line holding text.
export function prefixedText(head: string, indent: string, body: BlockText): BlockText {
- const length = body.length + head.length + indent.length * body.later.written
- return { body, first: headedLine(head, body.first), head, indent, kind: 'prefixed', later: body.later, length }
+ return measured({ body, head, indent, kind: 'prefixed' })
}
// Each line takes `> `, an empty one `>`.
export function quotedText(body: BlockText): BlockText {
+ return measured({ body, kind: 'quoted' })
+}
+
+function measured(lines: Lines): BlockText {
+ if (lines.kind === 'text') {
+ const [first = '', ...rest] = lines.lines
+ const empty = rest.filter((line) => line === '').length
+ const later = { blank: rest.filter((line) => line !== '' && isBlankLine(line)).length, empty, written: rest.length - empty }
+ const length = rest.reduce((sum, line) => sum + 1 + line.length, first.length)
+ return { ...lines, first: { blank: first !== '' && isBlankLine(first), character: first.charAt(0), ruleCharacters: holdsOnlyRuleCharacters(first), text: first }, later, length }
+ }
+ if (lines.kind === 'joined') {
+ const [first, ...others] = lines.parts
+ let { blank, empty, written } = first.later
+ let { length } = first
+ for (const part of others) {
+ blank += part.later.blank + (part.first.blank ? 1 : 0)
+ empty += part.later.empty + (part.first.text === '' ? 1 : 0)
+ written += part.later.written + (part.first.text === '' ? 0 : 1)
+ length += 1 + part.length
+ }
+ return { ...lines, first: first.first, later: { blank, empty, written }, length }
+ }
+ const { body } = lines
+ if (lines.kind === 'prefixed') return { ...lines, first: headedLine(lines.head, body.first), later: body.later, length: body.length + lines.head.length + lines.indent.length * body.later.written }
const head = body.first.text === '' ? '>' : '> '
const later = { blank: 0, empty: 0, written: body.later.empty + body.later.written }
- return { body, first: headedLine(head, body.first), kind: 'quoted', later, length: body.length + head.length + 2 * body.later.written + body.later.empty }
+ return { ...lines, first: headedLine(head, body.first), later, length: body.length + head.length + 2 * body.later.written + body.later.empty }
}
// A line holds only rule characters exactly when each of its parts does.
diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts
index dff4b89..e9349af 100644
--- a/src/markdown/emit/inline-line.ts
+++ b/src/markdown/emit/inline-line.ts
@@ -30,6 +30,15 @@ type Emission = { carries: [NodeRange, ...NodeRange[]]; segments?: undefined } |
type Fallback = [NodeRange, ...NodeRange[]] | 'opening-link'
+// How a line is judged and what its fallbacks do: the lossless writer carries nodes, the portable reduction drops marks.
+export type FallbackAction = {
+ giveUpEmphasis: (line: Line) => Line
+ judge: (line: Line, grouping: RunGrouping) => Result>
+ // A line whose fallbacks rewrite its nodes judges its spanned line again on every pass.
+ spansEachPass: boolean
+ take: (line: Line, fallback: LineFallback) => Result
+}
+
type InlineContext = {
atBlockEnd: boolean
bracketed: boolean
@@ -44,10 +53,13 @@ type InlineContext = {
type LineAttempt = { fallback: Fallback; line?: undefined; segments?: undefined } | { fallback?: undefined; line: string; segments: InlineSegment[] }
+// What one grouping writes, or the fallback it needs and the line that fallback's indexes name.
+export type LineJudgement = { fallback: LineFallback; kind: 'fallback'; line: Line } | { kind: 'written'; written: Written }
+
type LineFallbacks = { carried: Set; flavour: WrittenFlavour; openingLinkAsDirective: boolean }
// docs/decisions.md §After 32 passes, a line carries or drops every em, strike and strong mark.
-export const readBackPasses = 32
+const readBackPasses = 32
export type PortableLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
@@ -93,28 +105,44 @@ export function tryImageLine(alt: string | undefined, url: string, flavour: Writ
}
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result {
- const fallbacks = withheldMarkFallbacks(nodes, flavour)
- // spec/flavour.md, Marks: the spanned line where it reads back, else the parted line with every fallback it needs.
- const spanned = linePass(nodes, container, path, fallbacks, 'spanned')
- if (!spanned.ok) return spanned
- const { fallback, line, segments } = spanned.value
- if (fallback === undefined) return success({ line, openingLinkAsDirective: false, segments })
- return partedLine(nodes, container, path, fallbacks)
+ return settledLine(withheldMarkFallbacks(nodes, flavour), {
+ giveUpEmphasis: (fallbacks) => carryEveryEmphasis(fallbacks, nodes),
+ judge: (fallbacks, grouping) => writerJudgement(nodes, container, path, fallbacks, grouping),
+ spansEachPass: false,
+ take: (fallbacks, fallback) => {
+ const taken = takeFallback(fallbacks, fallback, path)
+ return taken.ok ? success(fallbacks) : taken
+ },
+ })
}
-function partedLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result {
- // Terminates because takeFallback refuses a pass that took no new fallback.
- for (let pass = 1; ; pass += 1) {
- const attempt = linePass(nodes, container, path, fallbacks, 'parted')
- if (!attempt.ok) return attempt
- const { fallback, line, segments } = attempt.value
- if (fallback === undefined) return success({ line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments })
- const taken = takeFallback(fallbacks, fallback, path)
+// spec/flavour.md, Marks: the spanned line where it reads back, else the parted line, taking one fallback a pass.
+export function settledLine(line: Line, action: FallbackAction): Result {
+ // Terminates because `take` refuses a pass that changes nothing.
+ for (let current = line, pass = 1; ; pass += 1) {
+ let parting = current
+ if (pass === 1 || action.spansEachPass) {
+ const spanned = action.judge(current, 'spanned')
+ if (!spanned.ok) return spanned
+ if (spanned.value.kind === 'written') return success(spanned.value.written)
+ parting = spanned.value.line
+ }
+ const parted = action.judge(parting, 'parted')
+ if (!parted.ok) return parted
+ if (parted.value.kind === 'written') return success(parted.value.written)
+ const taken = action.take(parted.value.line, parted.value.fallback)
if (!taken.ok) return taken
- if (pass === readBackPasses) carryEveryEmphasis(fallbacks, nodes)
+ current = pass === readBackPasses ? action.giveUpEmphasis(taken.value) : taken.value
}
}
+function writerJudgement(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks, grouping: RunGrouping): Result> {
+ const attempt = linePass(nodes, container, path, fallbacks, grouping)
+ if (!attempt.ok) return attempt
+ const { fallback, line, segments } = attempt.value
+ return success(fallback === undefined ? { kind: 'written', written: { line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments } } : { fallback, kind: 'fallback', line: fallbacks })
+}
+
export function linePass(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks, grouping: RunGrouping): Result {
const emission = lineSegments(nodes, container, path, fallbacks, grouping)
if (!emission.ok) return emission
@@ -122,8 +150,9 @@ export function linePass(nodes: readonly AdfNode[], container: LineContainer, pa
return success(segments === undefined ? { fallback: carries } : attemptLine(segments, container, fallbacks.flavour))
}
-function carryEveryEmphasis(fallbacks: LineFallbacks, nodes: readonly AdfNode[]): void {
+function carryEveryEmphasis(fallbacks: LineFallbacks, nodes: readonly AdfNode[]): LineFallbacks {
for (const [index, node] of nodes.entries()) if (nodeMarks(node).some(isEmphasis)) fallbacks.carried.add(index)
+ return fallbacks
}
// Seeds the withheld-mark carries once per line, so the run at each mark depth does not rescan every node's marks.
@@ -359,7 +388,7 @@ function tryLink(nodes: readonly [RunNode, ...RunNode[]], mark: AdfMark, depth:
const href = linkHref(nodeAttrs(mark))
if (href === undefined) return carriedRun(range, context)
const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective
- const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes.map(({ node }) => node), nodes[0].outer, context.bracketed, titleEscapes(context.flavour, context.container))
+ const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes.map(({ node }) => node), { bracketed: context.bracketed, escapes: titleEscapes(context.flavour, context.container), marksInside: nodes[0].outer })
if (commonMark === undefined) return undefined
if (commonMark.form === 'autolink') return success({ segments: [syntax(`<${href}>`)] })
const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true })
diff --git a/src/markdown/emit/spelling-questions.ts b/src/markdown/emit/spelling-questions.ts
index 1c5ec37..d876a84 100644
--- a/src/markdown/emit/spelling-questions.ts
+++ b/src/markdown/emit/spelling-questions.ts
@@ -1,3 +1,3 @@
// The only file `parse/` imports from `emit/` (docs/decisions.md §The source parts by ADF and format).
-export { commonMarkSpelling, type SpellingMemo } from './adf-to-markdown.ts'
+export { commonMarkSpelling, spellingMemo, type SpellingMemo } from './adf-to-markdown.ts'
export { openingLinkTakesDirective } from './inline-line.ts'
diff --git a/src/markdown/emit/unspellable-runs.ts b/src/markdown/emit/unspellable-runs.ts
index 6817539..8326016 100644
--- a/src/markdown/emit/unspellable-runs.ts
+++ b/src/markdown/emit/unspellable-runs.ts
@@ -14,35 +14,47 @@ type EmphasisPair = { closer: number; delimiter: string; nodes: MarkRun; opener:
// The pairs in closing order, and what the sweep has settled about them.
type PairSweep = {
readonly fallback: RunFallback
- // 1 where the pair is settled to fall.
- readonly fallen: Uint8Array
readonly first: number
- // The bounds of every run whose drop may merge runs: its first node's index and the index past its last.
- readonly mergingEnds: ReadonlySet
readonly output: string
// The pair each placed segment delimits, -1 where it delimits none.
readonly pairAt: Int32Array
readonly pairs: readonly EmphasisPair[]
readonly placed: readonly PlacedSegment[]
+ readonly verdicts: Verdict[]
}
// How another pair stands when the judged one falls: `moves` where a carry or a merge may move its delimiters, `unsettled` where it may stand or have fallen.
type Standing = 'fallen' | 'moves' | 'stands' | 'unsettled'
-// The runs this pass takes back: the first emphasis run the line misreads and every run that falls with it, else every misflanked highlight run. docs/decisions.md §With the first misread emphasis run, `unspellableRuns` returns only runs that, taken back early, leave the line as the one-run-per-pass reference writes it.
+// `merges` where a drop may merge runs at the pair's ends, so its delimiters may move; `falls` where it goes back with the first misread pair, itself included unless it merges; `kept` otherwise.
+type Verdict = 'falls' | 'kept' | 'merges'
+
+// The runs this pass takes back: the first emphasis run the line misreads and every run that falls with it, else every misflanked highlight run.
export function unspellableRuns(placed: readonly PlacedSegment[], reading: InlineReading, output: string, fallback: RunFallback): MarkRun[] {
const { pairAt, pairs } = emphasisPairs(placed)
const first = pairs.findIndex((pair) => !readAsWritten(reading, placed, pair))
const firstPair = pairs[first]
if (firstPair === undefined) return misflankedHighlights(placed, output)
- const sweep: PairSweep = { fallback, fallen: new Uint8Array(pairs.length), first, mergingEnds: mergingEnds(pairs, fallback), output, pairAt, pairs, placed }
- sweep.fallen[first] = 1
+ const verdicts = pairVerdicts({ fallback, first, output, pairAt, pairs, placed })
+ return pairs.reduce(
+ (runs, pair, index) => {
+ if (index !== first && verdicts[index] === 'falls') runs.push(pair.nodes)
+ return runs
+ },
+ [firstPair.nodes],
+ )
+}
+
+// docs/decisions.md §With the first misread emphasis run, `unspellableRuns` returns only runs that, taken back early, leave the line as the one-run-per-pass reference writes it.
+function pairVerdicts(line: Omit): Verdict[] {
+ const { fallback, first, pairs } = line
+ const ends = mergingEnds(pairs, fallback)
+ const verdicts = pairs.map((pair, index): Verdict => (mergesAt(ends, pair) ? 'merges' : index === first ? 'falls' : 'kept'))
+ const sweep: PairSweep = { ...line, verdicts }
// Each verdict reads the verdicts settled before it, so the sweep settles forward from the first pair, then back.
- for (let judged = first + 1; judged < pairs.length; judged += 1) if (fallsWithFirst(sweep, judged)) sweep.fallen[judged] = 1
- for (let judged = first - 1; judged >= 0; judged -= 1) if (fallsWithFirst(sweep, judged)) sweep.fallen[judged] = 1
- const runs = [firstPair.nodes]
- for (const [index, pair] of pairs.entries()) if (index !== first && sweep.fallen[index] === 1) runs.push(pair.nodes)
- return runs
+ for (let judged = first + 1; judged < pairs.length; judged += 1) if (fallsWithFirst(sweep, judged)) verdicts[judged] = 'falls'
+ for (let judged = first - 1; judged >= 0; judged -= 1) if (fallsWithFirst(sweep, judged)) verdicts[judged] = 'falls'
+ return verdicts
}
// The pairs in closing order.
@@ -97,18 +109,18 @@ function endsAlone(edge: AdfNode | undefined, beside: AdfNode | undefined, mark:
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.
+// A pair falls with the first where it falls cleanly, and cannot read whichever character it is written with.
function fallsWithFirst(sweep: PairSweep, judged: number): boolean {
if (!fallsCleanly(sweep, judged)) return false
const spellings = pairSpellings(sweep, judged, judged)
return spellings !== undefined && spellings.every((character) => cannotRead(sweep, judged, character))
}
-// A carry splits every pair holding it, so a pair falls cleanly under a carry only where it holds no pair spanning fewer nodes, and under a drop only where no drop may merge runs at its ends.
-function fallsCleanly({ fallback, mergingEnds, pairs }: PairSweep, judged: number): boolean {
+// A carry splits every pair holding it, so a pair falls cleanly under a carry only where it holds no pair spanning fewer nodes, and under a drop only where it does not merge.
+function fallsCleanly({ fallback, pairs, verdicts }: PairSweep, judged: number): boolean {
const pair = pairs[judged]
- if (pair === undefined) return false
- return fallback.kind === 'drop' ? !mergesAt(mergingEnds, pair) : !holdsPairBefore(pairs, judged, pair)
+ if (pair === undefined || verdicts[judged] === 'merges') return false
+ return fallback.kind === 'drop' || !holdsPairBefore(pairs, judged, pair)
}
function mergesAt(mergingEnds: ReadonlySet, { nodes }: EmphasisPair): boolean {
@@ -122,12 +134,12 @@ function cannotRead(sweep: PairSweep, judged: number, character: string): boolea
}
// Whether the judged pair holds a pair not settled to fall that may be written with `character`. Pairs close in order, so the pairs it holds close just before it.
-function holdsAlike({ fallen, pairs }: PairSweep, judged: number, character: string): boolean {
+function holdsAlike({ first, pairs, verdicts }: PairSweep, judged: number, character: string): boolean {
const pair = pairs[judged]
for (let held = judged - 1; pair !== undefined && held >= 0; held -= 1) {
const inner = pairs[held]
if (inner === undefined || inner.closer < pair.opener) return false
- if (fallen[held] === 0 && mayBeWrittenWith(inner, character)) return true
+ if (held !== first && verdicts[held] !== 'falls' && mayBeWrittenWith(inner, character)) return true
}
return false
}
@@ -205,13 +217,13 @@ function delimiterEdge(placed: readonly PlacedSegment[], at: number, step: -1 |
}
// Derived case by case in docs/decisions.md §With the first misread emphasis run, `unspellableRuns` returns only runs that, taken back early, leave the line as the one-run-per-pass reference writes it.
-function pairStanding({ fallback, fallen, first, mergingEnds, pairs }: PairSweep, judged: number, other: number): Standing {
+function pairStanding({ fallback, first, pairs, verdicts }: PairSweep, judged: number, other: number): Standing {
const pair = pairs[other]
if (other === judged) return 'stands'
- if (pair === undefined || mergesAt(mergingEnds, pair)) return 'moves'
+ if (pair === undefined || verdicts[other] === 'merges') return 'moves'
if (other > judged && judged > first) return 'stands'
if (fallback.kind === 'carry') return spanSameNodes(pair, pairs[judged]) ? 'stands' : 'moves'
- return fallen[other] === 1 ? 'fallen' : 'unsettled'
+ return verdicts[other] === 'kept' ? 'unsettled' : 'fallen'
}
// The characters the other pair may be written with when the judged one falls: em takes `_`, turning `*` beside a word character.
diff --git a/src/markdown/inline-content.ts b/src/markdown/inline-content.ts
index 1a5c852..7e1842f 100644
--- a/src/markdown/inline-content.ts
+++ b/src/markdown/inline-content.ts
@@ -276,7 +276,7 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[]
const href = linkHref(nodeAttrs(mark))
if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.shared.path)
if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.shared.path)
- if (commonMarkLink(nodeAttrs(mark), href, adfNodes(nodes), 0, scan.container === undefined, 'lossless') === undefined) return undefined
+ if (commonMarkLink(nodeAttrs(mark), href, adfNodes(nodes), { bracketed: scan.container === undefined, escapes: 'lossless', marksInside: 0 }) === undefined) return undefined
if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.shared.path)
scan.openingSpellableLink = true
return undefined
diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts
index 6718d8b..d0206e9 100644
--- a/src/markdown/mark-spellings.ts
+++ b/src/markdown/mark-spellings.ts
@@ -63,8 +63,11 @@ export function linkHref(attrs: AdfAttributes): string | undefined {
return typeof href === 'string' ? href : undefined
}
-// spec/flavour.md, Marks. `marksInside` counts the marks the link's nodes carry within it: the emitter's `outer`, the parser's 0.
-export function commonMarkLink(attrs: AdfAttributes, href: string, nodes: readonly AdfNode[], marksInside: number, bracketed: boolean, escapes: TitleEscapes): CommonMarkLink | undefined {
+// `marksInside` counts the marks the link's nodes carry within it: the emitter's `outer`, the parser's 0.
+type LinkSurroundings = { bracketed: boolean; escapes: TitleEscapes; marksInside: number }
+
+// spec/flavour.md, Marks.
+export function commonMarkLink(attrs: AdfAttributes, href: string, nodes: readonly AdfNode[], { bracketed, escapes, marksInside }: LinkSurroundings): CommonMarkLink | undefined {
if (Object.keys(attrs).some((key) => key !== 'href' && key !== 'title')) return undefined
const title = attrs['title']
const autolinkHolds = !bracketed || (!href.includes('`') && !holdsDirectivePrefix(href) && escapeUnbalanced(href, '[', ']') === href)
diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts
index a65b62b..48c534a 100644
--- a/src/markdown/parse/markdown-to-adf.ts
+++ b/src/markdown/parse/markdown-to-adf.ts
@@ -5,7 +5,7 @@ import type { ConvertFault } from '../../result.ts'
import type { LineContainer } from '../line-container.ts'
import type { LinkDefinitions } from '../inline-content.ts'
import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts'
-import { commonMarkSpelling, openingLinkTakesDirective, type SpellingMemo } from '../emit/spelling-questions.ts'
+import { commonMarkSpelling, openingLinkTakesDirective, spellingMemo, type SpellingMemo } from '../emit/spelling-questions.ts'
import { documentAttribute, documentName, documentSpelling } from '../directive/block.ts'
import { emptyKeys, nodeAttrs } from '../../adf/document.ts'
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result } from '../../result.ts'
@@ -55,7 +55,7 @@ function readMarkdown(markdown: string, readAs: ReadAs): Result {
const fault = documentFault(only)
return fault === undefined ? success({ type: 'doc', version: 1 }) : positioned(faulted(fault, []), only.position)
}
- const reading: Reading = { ...readAs, definitions: parsed.definitions, memo: new Map() }
+ const reading: Reading = { ...readAs, definitions: parsed.definitions, memo: spellingMemo() }
const content = readBlocks(parsed.blocks, reading, [], 0)
return content.ok ? success({ content: content.value, type: 'doc', version: 1 }) : content
}
diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts
index 9d959c3..cdf644a 100644
--- a/src/markdown/portable/adf-to-portable-markdown.test.ts
+++ b/src/markdown/portable/adf-to-portable-markdown.test.ts
@@ -70,6 +70,7 @@ test('refuses what the document guard refuses, and nothing else', () => {
let deepInline: AdfNode = text('x')
for (let level = 0; level <= largestNesting; level += 1) deepInline = { content: [deepInline], type: 'unknownInline' }
assert.match(portableDocument(document(paragraph(deepInline))), /^unsupported-nesting-depth at /)
+ assert.match(portableDocument(document(paragraph(text('a'), text('b'), deepInline))), /^unsupported-nesting-depth at \/content\/0\/content\/2(\/content\/0)+$/)
})
test('refuses a paragraph whose text would pass V8\'s longest string, joining none of it', () => {
diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts
index e55eee3..630f42b 100644
--- a/src/markdown/portable/adf-to-portable-markdown.ts
+++ b/src/markdown/portable/adf-to-portable-markdown.ts
@@ -1,10 +1,10 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts'
import { centeredImage, externalMedia } from '../../adf/plain-image.ts'
-import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
+import { commonMarkSpelling, largestListMarker, spellingMemo, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { flavourClaims } from '../flavour.ts'
-import { inlineLeaves, portableLink, reduceInline, writableHref } from './inline-reduction.ts'
+import { inlineLeaves, portableLink, reduceInline, writableHref, type ReductionPlace } from './inline-reduction.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { isBlockNodeType, oneLine } from '../../adf/shown-text.ts'
import { languageSlot } from '../code-language.ts'
@@ -14,11 +14,10 @@ import { nodeAttrs, nodeContent, nodeMarks, writableContent } from '../../adf/do
import { portableWriting } from './block-spelling.ts'
import { splitTaskList } from './task-list.ts'
import { taskMarker } from './conventions.ts'
-import { toEditorNormal } from './editor-normal.ts'
+import { editorNormalForm, sourceIndex, toEditorNormal } from './editor-normal.ts'
import { withinEngineLimits } from '../../length-bound.ts'
-// depth: the level the node reduced stands at, counted as the emitter counts it.
-type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
+type Reduction = ReductionPlace & { memo: SpellingMemo }
type BlockReducer = (node: AdfNode, reduction: Reduction) => Result
@@ -41,7 +40,7 @@ const blockReducers: Readonly> = {
extension: paragraphOfNode,
heading: reduceHeading,
media: reduceMedia,
- mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index) => reduceStanding(child, childReduction(reduction, index)))),
+ mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index, children) => reduceStanding(child, childReduction(reduction, children, index)))),
nestedExpand: reduceExpand,
orderedList: reduceList,
panel: reducePanel,
@@ -64,12 +63,9 @@ export function reduceToPortable(document: AdfDocument): Result {
const checked = writableContent(document)
if (!checked.ok) return checked
// Two documents the editor holds equal write the same portable markdown (docs/decisions.md §Equality is deep).
- const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
- if (!blocks.ok) {
- // Merging text renumbers siblings, so a refusal's path comes from the caller's document.
- const raw = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
- return !raw.ok && raw.error.code === blocks.error.code ? raw : blocks
- }
+ const { normal, sources } = editorNormalForm(document)
+ const blocks = reduceBlocks(nodeContent(normal), { depth: 0, memo: spellingMemo(), path: [], sources })
+ if (!blocks.ok) return blocks
return success(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 }))
}
@@ -83,7 +79,7 @@ function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result {
return reduceBlocks(nodeContent(node), { ...reduction, depth: reduction.depth + 1 })
}
-function childReduction(reduction: Reduction, index: number): Reduction {
- return { ...reduction, depth: reduction.depth + 1, path: [...reduction.path, 'content', index] }
+function childReduction(reduction: Reduction, children: readonly AdfNode[], index: number): Reduction {
+ return { ...reduction, depth: reduction.depth + 1, path: [...reduction.path, 'content', sourceIndex(children, index, reduction.sources)] }
}
function concatenated(results: readonly Result[]): Result {
@@ -183,7 +179,7 @@ function listOf(items: readonly AdfNode[], type: string): AdfNode[] {
}
function paragraphOf(nodes: readonly AdfNode[], reduction: Reduction): Result {
- const content = reduceInline(nodes, 'paragraph', reduction.path, reduction.depth)
+ const content = reduceInline(nodes, 'paragraph', reduction)
return content.ok ? success(content.value.length === 0 ? [] : [paragraph(content.value)]) : content
}
@@ -217,12 +213,12 @@ function withoutTrailingBlanks(text: string): string {
function reduceHeading(node: AdfNode, reduction: Reduction): Result {
const level = nodeAttrs(node)['level']
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return paragraphOf(nodeContent(node), reduction)
- const content = reduceInline(nodeContent(node), 'heading', reduction.path, reduction.depth)
+ const content = reduceInline(nodeContent(node), 'heading', reduction)
return content.ok ? success([{ attrs: { level }, content: content.value, type: 'heading' }]) : content
}
function reduceCodeBlock(node: AdfNode, reduction: Reduction): Result {
- const leaves = inlineLeaves(nodeContent(node), 'paragraph', reduction.path, reduction.depth)
+ const leaves = inlineLeaves(nodeContent(node), 'paragraph', reduction)
if (!leaves.ok) return leaves
const code = leaves.value.map((leaf) => leaf.text ?? '\n').join('')
const slot = languageSlot(nodeAttrs(node)['language'], flavourClaims.portable)
@@ -239,7 +235,7 @@ function reduceList(node: AdfNode, reduction: Reduction): Result {
}
function reduceItems(node: AdfNode, reduction: Reduction, itemBlocks: (child: AdfNode, at: Reduction) => Result): Result {
- const items = concatenated(nodeContent(node).map((child, index) => listItem(itemBlocks(child, childReduction(reduction, index)))))
+ const items = concatenated(nodeContent(node).map((child, index, children) => listItem(itemBlocks(child, childReduction(reduction, children, index)))))
return items.ok ? success(listOf(items.value, node.type === 'orderedList' ? 'orderedList' : 'bulletList')) : items
}
@@ -266,8 +262,9 @@ function blankedLines(code: readonly AdfNode[]): AdfNode[] {
// A task list opening with a task and holding tasks and task lists alone keeps its spelling; any other keeps its markers as text. A child reducing to nothing counts for neither.
function reduceTaskList(node: AdfNode, reduction: Reduction): Result {
const kept: { blocks: AdfNode[]; child: AdfNode }[] = []
- for (const [index, child] of nodeContent(node).entries()) {
- const at = childReduction(reduction, index)
+ const children = nodeContent(node)
+ for (const [index, child] of children.entries()) {
+ const at = childReduction(reduction, children, index)
const reduced = isTaskItem(child) ? reduceTask(child, at) : reduceStanding(child, at)
if (!reduced.ok) return reduced
if (reduced.value.length > 0) kept.push({ blocks: reduced.value, child })
@@ -285,7 +282,7 @@ function reduceTaskList(node: AdfNode, reduction: Reduction): Result
function reduceTask(task: AdfNode, at: Reduction): Result {
const attrs = { state: nodeAttrs(task)['state'] === 'DONE' ? 'DONE' : 'TODO' }
if (task.type === 'taskItem') {
- const content = reduceInline(nodeContent(task), 'paragraph', at.path, at.depth)
+ const content = reduceInline(nodeContent(task), 'paragraph', at)
return content.ok ? success([{ attrs, content: content.value, type: 'taskItem' }]) : content
}
const blocks = reduceBlocks(nodeContent(task), at)
@@ -301,11 +298,13 @@ function marked(blocks: readonly AdfNode[], marker: string): AdfNode[] {
function reduceTable(node: AdfNode, reduction: Reduction): Result {
const rows: PlacedCell[][] = []
- for (const [rowIndex, row] of nodeContent(node).entries()) {
- const rowReduction = childReduction(reduction, rowIndex)
+ const rowNodes = nodeContent(node)
+ for (const [rowIndex, row] of rowNodes.entries()) {
+ const rowReduction = childReduction(reduction, rowNodes, rowIndex)
const cells: PlacedCell[] = []
- for (const [cellIndex, cell] of (row.type === 'tableRow' ? nodeContent(row) : [row]).entries()) {
- const paragraph = cellParagraph(cell, childReduction(rowReduction, cellIndex))
+ const rowCells = row.type === 'tableRow' ? nodeContent(row) : [row]
+ for (const [cellIndex, cell] of rowCells.entries()) {
+ const paragraph = cellParagraph(cell, childReduction(rowReduction, rowCells, cellIndex))
if (!paragraph.ok) return paragraph
cells.push({ colspan: span(nodeAttrs(cell)['colspan']), paragraph: paragraph.value, rowspan: span(nodeAttrs(cell)['rowspan']) })
}
@@ -354,7 +353,7 @@ function setCell(grid: (AdfNode | undefined)[][], row: number, column: number, c
function cellParagraph(cell: AdfNode, reduction: Reduction): Result {
const blocks = cell.type === 'tableCell' || cell.type === 'tableHeader' ? nodeContent(cell) : [cell]
- const content = reduceInline(blocks, 'table-cell', reduction.path, reduction.depth)
+ const content = reduceInline(blocks, 'table-cell', reduction)
return content.ok ? success(content.value.length === 0 ? { type: 'paragraph' } : paragraph(content.value)) : content
}
diff --git a/src/markdown/portable/editor-normal.test.ts b/src/markdown/portable/editor-normal.test.ts
index 37dec43..765e725 100644
--- a/src/markdown/portable/editor-normal.test.ts
+++ b/src/markdown/portable/editor-normal.test.ts
@@ -3,7 +3,7 @@ import test from 'node:test'
import type { AdfNode } from '../../adf/document.ts'
import type { JsonValue } from '../../json-value.ts'
-import { toEditorNormal } from './editor-normal.ts'
+import { editorNormalForm, sourceIndex, toEditorNormal } from './editor-normal.ts'
test('merges adjacent text nodes carrying identical marks, at every level', () => {
const content: AdfNode[] = [
@@ -83,3 +83,12 @@ test('drops the content a text node holds, and joins the node as any other', ()
assert.deepEqual(normal([holding, { text: 'b', type: 'text' }]), [{ content: [{ text: 'ab', type: 'text' }], type: 'paragraph' }])
assert.deepEqual(normal([{ text: 'b', type: 'text' }, holding]), [{ content: [{ text: 'ba', type: 'text' }], type: 'paragraph' }])
})
+
+test('counts a node\'s index in a run of its siblings, or alone, in the document it was normalized from', () => {
+ const rule: AdfNode = { type: 'rule' }
+ const { normal, sources } = editorNormalForm({ content: [rule, { text: 'a', type: 'text' }, { text: 'b', type: 'text' }, { type: 'mention' }, { type: 'rule' }], type: 'doc', version: 1 })
+ const content = normal.content ?? []
+ assert.deepEqual(content.map((_, index) => sourceIndex(content, index, sources)), [0, 1, 3, 4])
+ assert.deepEqual([sourceIndex(content.slice(1, 3), 1, sources), sourceIndex(content.slice(2, 3), 0, sources)], [2, 0])
+ assert.equal(sourceIndex([rule], 0, sources), 0)
+})
diff --git a/src/markdown/portable/editor-normal.ts b/src/markdown/portable/editor-normal.ts
index aa88f1a..ab65c86 100644
--- a/src/markdown/portable/editor-normal.ts
+++ b/src/markdown/portable/editor-normal.ts
@@ -1,12 +1,15 @@
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import type { JsonValue } from '../../json-value.ts'
-import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
+import { mergedTextRuns, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { joinsWhenRead } from '../adjacent-text.ts'
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
type NodeHolder = { content?: AdfNode[] }
+// Each editor-normal node's index among its siblings in the document it was normalized from.
+export type SourceIndexes = ReadonlyMap
+
// The editor joins two text nodes where the reader would join their editor-normal forms.
export function joinsWhenEditorNormal(previous: AdfNode, node: AdfNode): boolean {
if (previous.type !== 'text' || node.type !== 'text') return false
@@ -14,13 +17,19 @@ export function joinsWhenEditorNormal(previous: AdfNode, node: AdfNode): boolean
}
export function toEditorNormal(document: AdfDocument): AdfDocument {
+ return editorNormalForm(document).normal
+}
+
+export function editorNormalForm(document: AdfDocument): { normal: AdfDocument; sources: SourceIndexes } {
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
+ const sources = new Map()
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
- const content = mergeAdjacentText(nodeContent(entry.source), joinsWhenEditorNormal)
+ const content = mergedTextRuns(nodeContent(entry.source), joinsWhenEditorNormal)
if (content.length === 0) continue
- entry.holder.content = content.map((source) => {
+ entry.holder.content = content.map(({ first, node: source }) => {
const holder = normalNode(source)
+ sources.set(holder, first)
// Atlassian's schema forbids content on a text node, so the editor drops it.
if (source.type !== 'text') pending.push({ holder, source })
return holder
@@ -28,7 +37,16 @@ export function toEditorNormal(document: AdfDocument): AdfDocument {
}
// ADF's schema requires content on doc, so an empty one stays.
if (document.content !== undefined && normal.content === undefined) normal.content = []
- return normal
+ return { normal, sources }
+}
+
+// A node's index in a list of siblings, or in a list of one, counted in the document the siblings were normalized from.
+export function sourceIndex(nodes: readonly AdfNode[], index: number, sources: SourceIndexes): number {
+ const first = nodes[0]
+ const node = nodes[index]
+ const from = first === undefined ? undefined : sources.get(first)
+ const at = node === undefined ? undefined : sources.get(node)
+ return from === undefined || at === undefined ? index : at - from
}
function normalNode(node: AdfNode): AdfNode {
diff --git a/src/markdown/portable/inline-reduction.test.ts b/src/markdown/portable/inline-reduction.test.ts
index e60aca4..b8968d4 100644
--- a/src/markdown/portable/inline-reduction.test.ts
+++ b/src/markdown/portable/inline-reduction.test.ts
@@ -6,13 +6,13 @@ 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 { judgedLine, portableLeaves, reduceInline, trimmedEdges, withoutFallback } from './inline-reduction.ts'
+import { portableJudgement, portableLeaves, reduceInline, withoutFallback } from './inline-reduction.ts'
import { success, type Result } from '../../result.ts'
test('reduces a line alike dropping every run a pass may or the first alone', () => {
fc.assert(
fc.property(markedLine, lineContainer, (nodes, container) => {
- assert.deepEqual(reduceInline(nodes, container, [], 0), firstRunLine(nodes, container))
+ assert.deepEqual(reduceInline(nodes, container, { depth: 0, path: [], sources: new Map() }), firstRunLine(nodes, container))
}),
// Lines the fixed seed rarely generates: a later run must not drop with the first, or one pass drops every copy of a repeated mark run.
{
@@ -58,18 +58,20 @@ test('refuses an unspellable-run fallback that takes no mark', () => {
assert.equal(refused.ok ? 'kept' : refused.error.code, 'unsupported-node-shape')
})
-// Drops only the first run each pass finds, with every misflanked highlight, and takes no cap.
+// Judges each pass spanned, else parted, dropping only the first run it finds, with every misflanked highlight, and takes no cap.
function firstRunLine(nodes: readonly AdfNode[], container: LineContainer): Result {
- const leaves = portableLeaves(nodes, container, [], 0)
+ const leaves = portableLeaves(nodes, container, { depth: 0, path: [], sources: new Map() })
if (!leaves.ok) return leaves
- for (let current = leaves.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), [])
+ for (let current: readonly AdfNode[] = leaves.value; ; ) {
+ const spanned = portableJudgement(current, 'spanned', container, [])
+ if (!spanned.ok) return spanned
+ if (spanned.value.kind === 'written') return success(spanned.value.written)
+ const parted = portableJudgement(spanned.value.line, 'parted', container, [])
+ if (!parted.ok) return parted
+ if (parted.value.kind === 'written') return success(parted.value.written)
+ const fixed = withoutFallback(parted.value.line, firstRun(parted.value.fallback), [])
if (!fixed.ok) return fixed
- current = trimmedEdges(fixed.value, 'spanned')
+ current = fixed.value
}
}
diff --git a/src/markdown/portable/inline-reduction.ts b/src/markdown/portable/inline-reduction.ts
index 7d016f9..5b8ada5 100644
--- a/src/markdown/portable/inline-reduction.ts
+++ b/src/markdown/portable/inline-reduction.ts
@@ -8,41 +8,54 @@ import { identicalMark, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } f
import { imageAside } from '../image-aside.ts'
import { insideRun, runsSharedWithNext } from '../emit/inline-runs.ts'
import { isEmphasis } from '../mark-spellings.ts'
-import { joinsWhenEditorNormal } from './editor-normal.ts'
+import { joinsWhenEditorNormal, sourceIndex, type SourceIndexes } from './editor-normal.ts'
import { largestNesting } from '../../nesting.ts'
import { partsShownText, referenceName, shownImage, shownLink, shownText, shownUrl } from '../../adf/shown-text.ts'
-import { portableLineFallback, readBackPasses, type PortableLineFallback } from '../emit/inline-line.ts'
+import { portableLineFallback, settledLine, type LineJudgement, 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[] }
+// How many of the leaf's marks edge whitespace keeps, or that it drops at a line edge.
+type EdgeOutcome = { kind: 'drop' } | { depth: number; kind: 'keep' }
+
+type PlacedLeaf = { after: Beside; before: Beside; leaf: AdfNode }
+
+// Where a reduction stands: its path, counted in the caller's document by `sources`, and its level as the emitter counts it.
+export type ReductionPlace = { depth: number; path: ConvertErrorPath; sources: SourceIndexes }
const highlight = 'backgroundColor'
const highlightMark: AdfMark = { type: highlight }
const edgeStrippingMarks: readonly string[] = [highlight, 'em', 'strike', 'strong']
const keptMarks: readonly string[] = [...edgeStrippingMarks, 'code', 'link']
-export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result {
- const leaves = portableLeaves(nodes, container, path, depth)
+export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, place: ReductionPlace): Result {
+ const leaves = portableLeaves(nodes, container, place)
if (!leaves.ok) return leaves
- return spellableLine(leaves.value, container, path)
+ const { path } = place
+ // Ends: withoutFallback refuses a pass that drops no mark, and trimmedEdges adds none.
+ return settledLine(leaves.value, {
+ giveUpEmphasis: withoutEmphasis,
+ judge: (line, grouping) => portableJudgement(line, grouping, container, path),
+ spansEachPass: true,
+ take: (line, fallback) => withoutFallback(line, fallback, path),
+ })
}
-export function portableLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result {
- const leaves = inlineLeaves(nodes, container, path, depth)
+// The leaves before the spanned trim `portableJudgement` gives them.
+export function portableLeaves(nodes: readonly AdfNode[], container: LineContainer, place: ReductionPlace): Result {
+ const leaves = inlineLeaves(nodes, container, place)
if (!leaves.ok) return leaves
- return success(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value, 'spanned')), 'spanned'))
+ return success(highlightsInsideSharedMarks(trimmedEdges(leaves.value, 'spanned')))
}
-export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result {
- if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path)
+export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer, place: ReductionPlace): Result {
+ if (place.depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, place.path)
const leaves: AdfNode[] = []
let joinsNext = false
for (const [index, node] of nodes.entries()) {
- const held = nodeLeaves(node, container, [...path, 'content', index], depth)
+ const held = nodeLeaves(node, container, { ...place, path: [...place.path, 'content', sourceIndex(nodes, index, place.sources)] })
if (!held.ok) return held
if (held.value.length === 0) continue
const block = partsShownText(node.type)
@@ -53,7 +66,7 @@ export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer
return success(leaves)
}
-function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorPath, depth: number): Result {
+function nodeLeaves(node: AdfNode, container: LineContainer, place: ReductionPlace): Result {
const marks = nodeMarks(node)
if (node.type === 'text') return success(textLeaves(node.text, marks, container))
if (node.type === 'hardBreak') return success([lineBreak(container)])
@@ -63,7 +76,7 @@ function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorP
if (['media', 'mediaInline'].includes(node.type)) return success(mediaLeaves(node, marks, container))
if (['blockCard', 'embedCard', 'inlineCard'].includes(node.type)) return success(cardLeaves(node, marks, container))
const own = textLeaves(node.text ?? shownText(node), marks, container)
- const held = inlineLeaves(nodeContent(node), container, path, depth + 1)
+ const held = inlineLeaves(nodeContent(node), container, { ...place, depth: place.depth + 1 })
if (!held.ok) return held
return success(own.length > 0 && held.value.length > 0 ? [...own, textLeaf(' ', []), ...held.value] : [...own, ...held.value])
}
@@ -173,11 +186,10 @@ export function trimmedEdges(leaves: readonly AdfNode[], grouping: RunGrouping):
// 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, grouping)
let changed = false
const trimmed: AdfNode[] = []
- for (const [index, leaf] of merged.entries()) {
- const edges = leafEdges(leaf, beside(merged[index - 1], shared[index - 1]), beside(merged[index + 1], shared[index]))
+ for (const { after, before, leaf } of placedLeaves(merged, grouping)) {
+ const edges = leafEdges(leaf, before, after)
if (edges === undefined) {
trimmed.push(leaf)
continue
@@ -198,6 +210,11 @@ function withoutEdgeBreaks(leaves: readonly AdfNode[]): AdfNode[] {
return leaves.slice(first, last + 1)
}
+function placedLeaves(leaves: readonly AdfNode[], grouping: RunGrouping): PlacedLeaf[] {
+ const shared = runsSharedWithNext(leaves, grouping)
+ return leaves.map((leaf, index) => ({ after: beside(leaves[index + 1], shared[index]), before: beside(leaves[index - 1], shared[index - 1]), leaf }))
+}
+
function beside(neighbour: AdfNode | undefined, sharedRuns = 0): Beside {
return { lineEdge: neighbour === undefined || neighbour.type === 'hardBreak', sharedRuns }
}
@@ -209,50 +226,34 @@ function leafEdges(leaf: AdfNode, before: Beside, after: Beside): AdfNode[] | un
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, 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 leading = edgeOutcome(marks, before, lead)
+ const trailing = edgeOutcome(marks, after, trail)
+ if (leading.kind === 'keep' && trailing.kind === 'keep' && leading.depth === marks.length && trailing.depth === marks.length) return undefined
+ if (lead === text) return leading.kind === 'drop' || trailing.kind === 'drop' ? [] : [textLeaf(text, marks.slice(0, Math.min(leading.depth, trailing.depth)))]
const edges: AdfNode[] = []
- if (lead !== '' && leadDepth !== undefined) edges.push(textLeaf(lead, marks.slice(0, leadDepth)))
+ if (lead !== '' && leading.kind === 'keep') edges.push(textLeaf(lead, marks.slice(0, leading.depth)))
const core = text.slice(lead.length, text.length - trail.length)
if (core !== '') edges.push(textLeaf(core, marks))
- if (trail !== '' && trailDepth !== undefined) edges.push(textLeaf(trail, marks.slice(0, trailDepth)))
+ if (trail !== '' && trailing.kind === 'keep') edges.push(textLeaf(trail, marks.slice(0, trailing.depth)))
return edges
}
-// 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
+// Text not marked `code` holds every run over it, so the runs it shares are its outermost marks.
+function edgeOutcome(marks: readonly AdfMark[], { lineEdge, sharedRuns }: Beside, whitespace: string): EdgeOutcome {
+ if (whitespace === '') return { depth: marks.length, kind: 'keep' }
// 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
+ if (lineEdge && sharedRuns > 0) return { kind: 'drop' }
const stripping = marks.findIndex((mark, index) => index >= sharedRuns && edgeStrippingMarks.includes(mark.type))
- const kept = stripping === -1 ? marks.length : stripping
- return kept === 0 && lineEdge ? undefined : kept
+ const depth = stripping === -1 ? marks.length : stripping
+ return depth === 0 && lineEdge ? { kind: 'drop' } : { depth, kind: 'keep' }
}
-function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result {
- // 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 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, 'spanned')
- }
-}
-
-// Judged as the writer writes a line: spanned where that reads back, else parted and trimmed by the parted runs, the line a fallback's indexes name.
-export function judgedLine(spanned: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result {
- const spannedFallback = portableLineFallback(spanned, container, path, 'spanned')
- if (!spannedFallback.ok) return spannedFallback
- if (spannedFallback.value === undefined) return success({ fallback: undefined, leaves: [...spanned] })
- const parted = trimmedEdges(spanned, 'parted')
- const partedFallback = portableLineFallback(parted, container, path, 'parted')
- if (!partedFallback.ok) return partedFallback
- return success({ fallback: partedFallback.value, leaves: parted })
+// Each grouping's line trimmed by its own runs, which a fallback's indexes name.
+export function portableJudgement(leaves: readonly AdfNode[], grouping: RunGrouping, container: LineContainer, path: ConvertErrorPath): Result> {
+ const line = trimmedEdges(leaves, grouping)
+ const fallback = portableLineFallback(line, container, path, grouping)
+ if (!fallback.ok) return fallback
+ return success(fallback.value === undefined ? { kind: 'written', written: line } : { fallback: fallback.value, kind: 'fallback', line })
}
export function withoutFallback(leaves: readonly AdfNode[], fallback: PortableLineFallback, path: ConvertErrorPath): Result {
diff --git a/todo.md b/todo.md
index 49a74e5..a8a4e94 100644
--- a/todo.md
+++ b/todo.md
@@ -378,7 +378,9 @@ they are handed, most of them returning `void`, and `parseInlineContent` reads a
output.
Once `Scan`'s pieces have owners, the bracket resolution — `linkedAlt` through `linkTo`,
`holdsLink`, and `closeBracket` through `altText` — can leave `inline-content.ts` (about 690 lines)
-for a module of its own.
+for a module of its own. That module resolves an image once, into a node holding its alt text and
+its link, so `imageAlt`, `resolvedPieces` and `directiveMarkPieces` no longer filter and regroup
+`ImageLink` pieces. Found by the architecture pass of the release review of 0.3.0, 2026-11-08.
### 118. Spell a portable link's title through `oneLine`.