10a - adfToPlainMarkdown's ADF→ADF reduction #128

Merged
lilleman merged 20 commits from 10a into main 2026-09-25 19:42:37 +02:00
4 changed files with 44 additions and 38 deletions
Showing only changes of commit 8418150cd3 - Show all commits
+1 -1
View File
@@ -24,7 +24,7 @@ export type SpellingMemo = Map<AdfNode, KeptSpelling>
type Walk = { blocks: readonly PlacedBlock[]; headroom: number } type Walk = { blocks: readonly PlacedBlock[]; headroom: number }
type WalkedItem = { node: AdfNode; walk: Walk } type WalkedItem = { node: AdfNode; walk: Walk }
const largestListMarker = 999999999 export const largestListMarker = 999999999
// Bare because tryList admits no item carrying attributes, marks or text. // Bare because tryList admits no item carrying attributes, marks or text.
const listItemOpener = spellDirectiveOpener('listItem', undefined, '') const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
+16 -18
View File
@@ -35,7 +35,7 @@ type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } |
type LineFallbacks = { carried: Set<number>; openingLinkAsDirective: boolean } type LineFallbacks = { carried: Set<number>; openingLinkAsDirective: boolean }
export type PlainLineFallback = { kind: 'claimed-line'; line: number } | { kind: 'opening-link' } | { kind: 'unspellable-run'; run: MarkRun } export type PlainLineFallback = { kind: 'claimed-line'; line: number; text: string } | { kind: 'opening-link' } | { kind: 'unspellable-run'; run: MarkRun }
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> { export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
const emitted = emitLine(nodes, container, path) const emitted = emitLine(nodes, container, path)
@@ -53,11 +53,8 @@ export function plainLineFallback(nodes: readonly AdfNode[], container: LineCont
const emission = lineSegments(nodes, container, path, { carried: new Set(), openingLinkAsDirective: false }) const emission = lineSegments(nodes, container, path, { carried: new Set(), openingLinkAsDirective: false })
if (!emission.ok) return emission if (!emission.ok) return emission
if (emission.value.carry !== undefined) return failure('unsupported-node-shape', 'an inline node on a plain line has no spelling but the carry', path) if (emission.value.carry !== undefined) return failure('unsupported-node-shape', 'an inline node on a plain line has no spelling but the carry', path)
const assembled = assembleInlineLine(emission.value.segments, container) const verdict = lineVerdict(emission.value.segments, container)
if (assembled.openingLinkAsDirective) return success({ kind: 'opening-link' }) return success(verdict.kind === 'line' ? undefined : verdict)
if (assembled.unspellableRun !== undefined) return success({ kind: 'unspellable-run', run: assembled.unspellableRun })
const claimed = claimedLine(assembled.line, container)
return success(claimed === undefined ? undefined : { kind: 'claimed-line', line: claimed })
} }
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined { export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
@@ -118,20 +115,21 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path:
} }
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> { function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> {
const assembled = assembleInlineLine(segments, container) const verdict = lineVerdict(segments, container)
if (assembled.openingLinkAsDirective) return success({ fallback: 'opening-link' }) if (verdict.kind === 'opening-link') return success({ fallback: 'opening-link' })
if (assembled.unspellableRun !== undefined) return success({ fallback: assembled.unspellableRun }) if (verdict.kind === 'unspellable-run') return success({ fallback: verdict.run })
const claimed = claimedLine(assembled.line, container) if (verdict.kind === 'claimed-line') return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(verdict.text)}`, path)
if (claimed !== undefined) { return success({ line: verdict.text })
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(assembled.line.split('\n')[claimed])}`, path)
}
return success({ line: assembled.line })
} }
function claimedLine(line: string, container: LineContainer): number | undefined { // The fallbacks in the order a line takes them, or the line where it takes none.
if (container !== 'paragraph') return undefined function lineVerdict(segments: readonly InlineSegment[], container: LineContainer): PlainLineFallback | { kind: 'line'; text: string } {
const index = line.split('\n').findIndex((single, index) => claimsLine(single, index === 0 ? 'first' : 'later')) const assembled = assembleInlineLine(segments, container)
return index === -1 ? undefined : index if (assembled.openingLinkAsDirective) return { kind: 'opening-link' }
if (assembled.unspellableRun !== undefined) return { kind: 'unspellable-run', run: assembled.unspellableRun }
const lines = assembled.line.split('\n')
const claimed = container === 'paragraph' ? lines.findIndex((single, index) => claimsLine(single, index === 0 ? 'first' : 'later')) : -1
return claimed === -1 ? { kind: 'line', text: assembled.line } : { kind: 'claimed-line', line: claimed, text: lines[claimed] ?? '' }
} }
// spec/flavour.md, Inline nodes. // spec/flavour.md, Inline nodes.
+1 -1
View File
@@ -87,7 +87,7 @@ function mediaLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[],
} }
function noteLeaves(name: string): AdfNode[] { function noteLeaves(name: string): AdfNode[] {
return [textLeaf(`(${name} not included)`, [{ type: 'em' }])] return [textLeaf(`(${name.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ')} not included)`, [{ type: 'em' }])]
} }
function nonEmpty(value: unknown): string | undefined { function nonEmpty(value: unknown): string | undefined {
+26 -18
View File
@@ -1,7 +1,7 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts' import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts' import { blockNodeModel } from '../../adf/block-nodes.ts'
import { commonMarkSpelling, type SpellingMemo } from './adf-to-markdown.ts' import { commonMarkSpelling, largestListMarker, type SpellingMemo } from './adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineLeaves, isBlockNodeType, reduceInline, writableHref } from './plain-inline.ts' import { inlineLeaves, isBlockNodeType, reduceInline, writableHref } from './plain-inline.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts'
@@ -107,26 +107,29 @@ function concatenated(results: readonly Result<AdfNode[]>[]): Result<AdfNode[]>
return success(blocks) return success(blocks)
} }
// A list or a table still taking the directive form gives way to its blocks. // A list still taking the directive form gives way to its items' blocks.
function plainSequence(blocks: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> { function plainSequence(blocks: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
let sequence = mergedLists(blocks.filter((block) => block.type !== 'paragraph' || nodeContent(block).length > 0)) let sequence = mergedLists(blocks.filter((block) => block.type !== 'paragraph' || nodeContent(block).length > 0))
for (let index = 0; index < sequence.length; index += 1) { for (let index = 0; index < sequence.length; index += 1) {
const block = sequence[index] const listed = sequence[index]
if (block === undefined || !['bulletList', 'orderedList', 'table'].includes(block.type)) continue if (listed === undefined || (listed.type !== 'bulletList' && listed.type !== 'orderedList')) continue
const block = numberedPastMarkers(listed)
sequence[index] = block
if (commonMarkSpelling(block, reduction.path, reduction.depth, reduction.memo)?.ok === true) continue if (commonMarkSpelling(block, reduction.path, reduction.depth, reduction.memo)?.ok === true) continue
sequence = mergedLists([...sequence.slice(0, index), ...heldBlocks(block), ...sequence.slice(index + 1)]) const held = nodeContent(block).flatMap(nodeContent)
index = Math.max(-1, index - 2) const from = Math.max(0, index - 1)
sequence = [...sequence.slice(0, from), ...mergedLists([...sequence.slice(from, index), ...held, ...sequence.slice(index + 1, index + 2)]), ...sequence.slice(index + 2)]
index = from - 1
} }
return success(sequence) return success(sequence)
} }
function heldBlocks(node: AdfNode): AdfNode[] { // A numbered list whose markers run past CommonMark's keeps its numbers as text in a bullet list.
const blocks: AdfNode[] = [] function numberedPastMarkers(list: AdfNode): AdfNode {
for (const child of nodeContent(node)) { const order = nodeAttrs(list)['order']
const held = ['listItem', 'tableCell', 'tableHeader', 'tableRow'].includes(child.type) ? heldBlocks(child) : [child] const items = nodeContent(list)
for (const block of held) blocks.push(block) if (list.type !== 'orderedList' || typeof order !== 'number' || order + items.length - 1 <= largestListMarker) return list
} return { content: items.map((item, offset) => itemOf(marked(nodeContent(item), `${order + offset}.`))), type: 'bulletList' }
return blocks
} }
function mergedLists(blocks: readonly AdfNode[]): AdfNode[] { function mergedLists(blocks: readonly AdfNode[]): AdfNode[] {
@@ -214,7 +217,8 @@ function listItem(blocks: Result<AdfNode[]>): Result<AdfNode[]> {
// A list item's first line reads as no rule and holds no line of spaces alone: the rule and the spaces give way. // A list item's first line reads as no rule and holds no line of spaces alone: the rule and the spaces give way.
function itemOf(blocks: readonly AdfNode[]): AdfNode { function itemOf(blocks: readonly AdfNode[]): AdfNode {
const content = (blocks[0]?.type === 'rule' ? blocks.slice(1) : blocks).map((block) => (block.type === 'codeBlock' ? { ...block, content: nodeContent(block).map(blankedLines) } : block)) const rules = blocks.findIndex((block) => block.type !== 'rule')
const content = blocks.slice(rules === -1 ? blocks.length : rules).map((block) => (block.type === 'codeBlock' ? { ...block, content: nodeContent(block).map(blankedLines) } : block))
return { content, type: 'listItem' } return { content, type: 'listItem' }
} }
@@ -241,10 +245,14 @@ function taskBlocks(child: AdfNode, at: Reduction): Result<AdfNode[]> {
} }
if (child.type !== 'blockTaskItem') return reduceStanding(child, at) if (child.type !== 'blockTaskItem') return reduceStanding(child, at)
const blocks = reduceBlocks(nodeContent(child), at) const blocks = reduceBlocks(nodeContent(child), at)
if (!blocks.ok) return blocks return blocks.ok ? success(marked(blocks.value, marker)) : blocks
const [first, ...rest] = blocks.value }
if (first?.type === 'paragraph') return success([paragraph([text(`${marker} `), ...nodeContent(first)]), ...rest])
return success([paragraph([text(marker)]), ...blocks.value]) // The marker leads the first paragraph, or stands as one where the blocks open with another.
function marked(blocks: readonly AdfNode[], marker: string): AdfNode[] {
const [first, ...rest] = blocks
if (first?.type === 'paragraph') return [paragraph([text(`${marker} `), ...nodeContent(first)]), ...rest]
return [paragraph([text(marker)]), ...blocks]
} }
function reduceTable(node: AdfNode, reduction: Reduction): Result<AdfNode[]> { function reduceTable(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {