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

Merged
lilleman merged 20 commits from 10a into main 2026-09-25 19:42:37 +02:00
8 changed files with 997 additions and 27 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ export type SpellingMemo = Map<AdfNode, KeptSpelling>
type Walk = { blocks: readonly PlacedBlock[]; headroom: number }
type WalkedItem = { node: AdfNode; walk: Walk }
const largestListMarker = 999999999
export const largestListMarker = 999999999
// Bare because tryList admits no item carrying attributes, marks or text.
const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
+27 -11
View File
@@ -1,7 +1,7 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
import type { LineContainer } from '../line-container.ts'
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type NodeRange } from './line-escaping.ts'
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts'
import { carriedInline } from '../opaque-carry.ts'
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
@@ -35,6 +35,8 @@ type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } |
type LineFallbacks = { carried: Set<number>; openingLinkAsDirective: boolean }
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> {
const emitted = emitLine(nodes, container, path)
if (!emitted.ok) return emitted
@@ -47,6 +49,14 @@ export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: Conve
return success(emitted.value.openingLinkAsDirective)
}
export function plainLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<PlainLineFallback | undefined> {
const emission = lineSegments(nodes, container, path, { carried: new Set(), openingLinkAsDirective: false })
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)
const verdict = lineVerdict(emission.value.segments, container)
return success(verdict.kind === 'line' ? undefined : verdict)
}
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
const emitted = emitLine(nodes, 'table-cell', path)
if (!emitted.ok) return undefined
@@ -105,15 +115,21 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path:
}
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> {
const verdict = lineVerdict(segments, container)
if (verdict.kind === 'opening-link') return success({ fallback: 'opening-link' })
if (verdict.kind === 'unspellable-run') return success({ fallback: verdict.run })
if (verdict.kind === 'claimed-line') return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(verdict.text)}`, path)
return success({ line: verdict.text })
}
// The fallbacks in the order a line takes them, or the line where it takes none.
function lineVerdict(segments: readonly InlineSegment[], container: LineContainer): PlainLineFallback | { kind: 'line'; text: string } {
const assembled = assembleInlineLine(segments, container)
if (assembled.openingLinkAsDirective) return success({ fallback: 'opening-link' })
if (assembled.unspellableRun !== undefined) return success({ fallback: assembled.unspellableRun })
for (const [index, single] of assembled.line.split('\n').entries()) {
if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) {
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(single)}`, path)
}
}
return success({ line: assembled.line })
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.
@@ -275,9 +291,9 @@ function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number
const carried = carryStrippedWhitespace(inner.value.segments)
return success({
segments: [
{ emphasis: 'open', escaping: 'none', nodes: range, text: spelling },
{ emphasis: 'open', escaping: 'none', nodes: { ...range, depth }, text: spelling },
...carried,
{ emphasis: 'close', escaping: 'none', nodes: range, text: spelling },
{ emphasis: 'close', escaping: 'none', nodes: { ...range, depth }, text: spelling },
],
})
}
+7 -5
View File
@@ -13,12 +13,14 @@ export type InlineEscaping = 'backslash' | 'bracketed' | 'bracketed-link-target'
export type NodeRange = { first: number; last: number }
export type MarkRun = NodeRange & { depth: number }
export type InlineSegment =
| { emphasis: EmphasisRole; escaping: 'none'; nodes: NodeRange; text: string }
| { emphasis: EmphasisRole; escaping: 'none'; nodes: MarkRun; text: string }
| { emphasis?: undefined; escaping: 'none'; nodes: NodeRange; text: string }
| { emphasis?: undefined; escaping: InlineEscaping; nodes?: undefined; text: string }
export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRun: NodeRange | undefined }
export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRun: MarkRun | undefined }
type ScanLine = { position: LinePosition; start: number; text: string }
@@ -132,7 +134,7 @@ function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], cl
return escaped
}
function unspellableRun(segments: readonly InlineSegment[], output: string, placements: readonly number[]): NodeRange | undefined {
function unspellableRun(segments: readonly InlineSegment[], output: string, placements: readonly number[]): MarkRun | undefined {
const { nodes, runs } = emittedRuns(segments, placements, output)
const pair = misflanked(runs) ?? unpaired(runs)
return pair === undefined ? undefined : nodes[pair]
@@ -168,9 +170,9 @@ function delimiterAt(run: EmittedRun, closes: boolean, offset: number, width: nu
return run.delimiters.find((delimiter) => delimiter.closes === closes && delimiter.offset === offset && delimiter.width === width)
}
function emittedRuns(segments: readonly InlineSegment[], placements: readonly number[], output: string): { nodes: NodeRange[]; runs: EmittedRun[] } {
function emittedRuns(segments: readonly InlineSegment[], placements: readonly number[], output: string): { nodes: MarkRun[]; runs: EmittedRun[] } {
const runs: EmittedRun[] = []
const nodes: NodeRange[] = []
const nodes: MarkRun[] = []
const open: number[] = []
let cursor = 0
for (const segment of segments) {
+285
View File
@@ -0,0 +1,285 @@
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
import type { LineContainer } from '../line-container.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { largestNesting } from '../../nesting.ts'
import { mergeAdjacentText, sameMark } from '../../adf/editor-normal.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { plainLineFallback, type PlainLineFallback } from './inline-line.ts'
import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
const highlight = 'backgroundColor'
const highlightDelimiter = '=='
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<AdfNode[]> {
const leaves = inlineLeaves(nodes, container, path, depth)
if (!leaves.ok) return leaves
return spellableLine(trimmedEdges(highlighted(trimmedEdges(leaves.value))), container, path)
}
export function isBlockNodeType(type: string): boolean {
return blockNodeModel(type) !== undefined || type === 'blockCard' || type === 'embedCard'
}
export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path)
const leaves: AdfNode[] = []
let joinsNext = false
for (const [index, node] of nodes.entries()) {
const held = nodeLeaves(node, container, [...path, 'content', index], depth)
if (!held.ok) return held
if (held.value.length === 0) continue
const block = isBlockNodeType(node.type) && node.type !== 'media'
if (leaves.length > 0 && (joinsNext || block)) leaves.push(textLeaf(' ', []))
joinsNext = block
for (const leaf of held.value) leaves.push(leaf)
}
return success(leaves)
}
function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
const marks = nodeMarks(node)
const attrs = nodeAttrs(node)
if (node.type === 'text') return success(textLeaves(node.text, marks, container))
if (node.type === 'hardBreak') return success([lineBreak(container)])
if (node.type === 'date') return success(textLeaves(isoDate(attrs['timestamp']), marks, container))
if (node.type === 'emoji') return success(textLeaves(nonEmpty(attrs['text']) ?? attrs['shortName'], marks, container))
if (node.type === 'placeholder') return success([])
if (node.type === 'mention') return success(textLeaves(nonEmpty(attrs['text']) ?? idMention(attrs['id']), marks, container))
if (node.type === 'status') return success(textLeaves(attrs['text'], marks, container))
if (['extension', 'inlineExtension'].includes(node.type)) return success(textLeaves(nonEmpty(attrs['text']), marks, container, noteName(attrs['extensionKey']) ?? 'extension'))
if (node.type === 'syncBlock') return success(noteLeaves('synced block'))
if (['media', 'mediaInline'].includes(node.type)) return success(mediaLeaves(attrs, marks, container))
if (['blockCard', 'embedCard', 'inlineCard'].includes(node.type)) return success(cardLeaves(attrs, marks, container))
const own = textLeaves(node.text ?? (['expand', 'nestedExpand'].includes(node.type) ? attrs['title'] : undefined), marks, container)
const held = inlineLeaves(nodeContent(node), container, path, depth + 1)
if (!held.ok) return held
return success(own.length > 0 && held.value.length > 0 ? [...own, textLeaf(' ', []), ...held.value] : [...own, ...held.value])
}
function cardLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
const data = attrs['data']
const held = typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {}
const url = nonEmpty(attrs['url'])
const heldUrl = nonEmpty(held['url'])
const name = nonEmpty(held['name'])
const href = url ?? heldUrl
if (href === undefined) return textLeaves(name, marks, container, 'link card')
return linkedLeaves(url ?? name ?? href, href, marks, container)
}
function linkedLeaves(text: string, href: string, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
return textLeaves(text, [...marks.filter((mark) => mark.type !== 'link'), { attrs: { href }, type: 'link' }], container)
}
function idMention(id: unknown): string | undefined {
return typeof id === 'string' && id !== '' ? `@${id}` : undefined
}
// An image standing inline is a link to it: CommonMark's inline image reads back as no node.
function mediaLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
const alt = nonEmpty(attrs['alt'])
const url = nonEmpty(attrs['url'])
if (attrs['type'] !== 'external' || url === undefined) return textLeaves(alt, marks, container, 'image')
return linkedLeaves(alt ?? url, url, marks, container)
}
function noteLeaves(name: string): AdfNode[] {
return [textLeaf(`(${name} not included)`, [{ type: 'em' }])]
}
function noteName(value: unknown): string | undefined {
return nonEmpty(value) === undefined ? undefined : oneLine(String(value)).trim()
}
export function oneLine(text: string): string {
return text.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ')
}
function nonEmpty(value: unknown): string | undefined {
return typeof value === 'string' && oneLine(value).trim() !== '' ? value : undefined
}
function isoDate(timestamp: unknown): string | undefined {
const milliseconds = typeof timestamp === 'string' && /^-?\d+$/.test(timestamp) ? Number(timestamp) : Number.NaN
const date = new Date(milliseconds)
if (Number.isNaN(date.getTime())) return undefined
const iso = date.toISOString()
return iso.slice(0, iso.indexOf('T'))
}
function lineBreak(container: LineContainer): AdfNode {
return container === 'paragraph' ? { type: 'hardBreak' } : textLeaf(' ', [])
}
function textLeaves(value: unknown, marks: readonly AdfMark[], container: LineContainer, note?: string): AdfNode[] {
if (typeof value !== 'string') return note === undefined ? [] : noteLeaves(note)
const text = value.replace(/[\r\u0000]/g, '')
const kept = plainMarks(marks, container, text)
const leaves: AdfNode[] = []
for (const [index, line] of text.split('\n').entries()) {
if (index > 0) leaves.push(lineBreak(container))
if (line !== '') leaves.push(textLeaf(line, kept))
}
return leaves
}
function textLeaf(text: string, marks: readonly AdfMark[]): AdfNode {
return marks.length === 0 ? { text, type: 'text' } : { marks: [...marks], text, type: 'text' }
}
// A highlight goes outermost so its run is one run at depth 0, and code innermost, the only place its spelling holds.
function plainMarks(marks: readonly AdfMark[], container: LineContainer, text: string): AdfMark[] {
const kept: AdfMark[] = []
for (const mark of marks) {
if (!keptMarks.includes(mark.type) || kept.some((held) => held.type === mark.type)) continue
if (mark.type === 'code' && container === 'table-cell' && text.includes('|')) continue
const plain = mark.type === 'link' ? plainLink(mark, container) : { type: mark.type }
if (plain !== undefined) kept.push(plain)
}
const rank = (mark: AdfMark): number => (mark.type === highlight ? 0 : mark.type === 'code' ? 2 : 1)
return kept.sort((first, second) => rank(first) - rank(second))
}
function plainLink(mark: AdfMark, container: LineContainer): AdfMark | undefined {
const attrs = nodeAttrs(mark)
const held = attrs['href']
const title = typeof attrs['title'] === 'string' ? attrs['title'].replace(/\r/g, '').replace(/\n/g, ' ') : undefined
if (typeof held !== 'string') return undefined
const href = writableHref(container === 'table-cell' ? held.replaceAll('|', '%7C') : held)
if (title === undefined || spellLinkTarget(href, title) === undefined || (container === 'table-cell' && title.includes('|'))) return { attrs: { href }, type: 'link' }
return { attrs: { href, title }, type: 'link' }
}
// spec/flavour.md, Links: the characters no destination spelling holds, then an ampersand an entity reference would read.
export function writableHref(href: string): string {
let written = href
for (const unwritable of [/[\u0000-\u001f\u007f\\<>]/g, /&/g]) {
if (spellDestination(written) !== undefined) return written
written = written.replace(unwritable, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`)
}
return written
}
// The delimiters carry the marks the whole run shares, so they open and close inside them.
function highlighted(leaves: readonly AdfNode[]): AdfNode[] {
const spelled: AdfNode[] = []
let run: AdfNode[] = []
let shared: AdfMark[] = []
for (const leaf of [...leaves, { type: 'hardBreak' }]) {
const marks = nodeMarks(leaf)
if (marks[0]?.type === highlight) {
const held = marks.slice(1)
shared = run.length === 0 ? held.filter((mark) => mark.type !== 'code') : shared.filter((mark) => held.some((other) => sameMark(other, mark)))
run.push(withMarks(leaf, held))
continue
}
if (run.length > 0) for (const held of [textLeaf(highlightDelimiter, shared), ...run, textLeaf(highlightDelimiter, shared)]) spelled.push(held)
run = []
spelled.push(leaf)
}
return spelled.slice(0, -1)
}
function withMarks(leaf: AdfNode, marks: readonly AdfMark[]): AdfNode {
const { marks: _, ...unmarked } = leaf
return marks.length === 0 ? unmarked : { ...unmarked, marks: [...marks] }
}
function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] {
for (let current = leaves; ; ) {
const merged = withoutEdgeBreaks(mergeAdjacentText(current))
let changed = false
const trimmed: AdfNode[] = []
for (const [index, leaf] of merged.entries()) {
const edges = leafEdges(leaf, merged[index - 1], merged[index + 1])
if (edges === undefined) {
trimmed.push(leaf)
continue
}
changed = true
for (const edge of edges) trimmed.push(edge)
}
if (!changed) return merged
current = trimmed
}
}
function withoutEdgeBreaks(leaves: readonly AdfNode[]): AdfNode[] {
let first = 0
let last = leaves.length - 1
while (leaves[first]?.type === 'hardBreak') first += 1
while (last >= first && leaves[last]?.type === 'hardBreak') last -= 1
return leaves.slice(first, last + 1)
}
// 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, previous: AdfNode | undefined, next: AdfNode | undefined): 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 = lead === text ? '' : text.slice(text.search(/[ \t]*$/))
const leadDepth = edgeDepth(marks, previous, lead)
const trailDepth = edgeDepth(marks, next, trail)
if (leadDepth === marks.length && trailDepth === marks.length) return undefined
const edges: AdfNode[] = []
if (lead !== '' && leadDepth !== undefined) edges.push(textLeaf(lead, marks.slice(0, leadDepth)))
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)))
return edges
}
function edgeDepth(marks: readonly AdfMark[], neighbour: AdfNode | undefined, whitespace: string): number | undefined {
if (whitespace === '') return marks.length
const lineEdge = neighbour === undefined || neighbour.type === 'hardBreak'
const neighbourMarks = lineEdge ? [] : nodeMarks(neighbour)
let shared = 0
while (shared < marks.length && sameMarkAt(marks, neighbourMarks, shared)) shared += 1
const stripping = marks.findIndex((mark, index) => index >= shared && edgeStrippingMarks.includes(mark.type))
const kept = stripping === -1 ? marks.length : stripping
return kept === 0 && lineEdge ? undefined : kept
}
function sameMarkAt(marks: readonly AdfMark[], others: readonly AdfMark[], index: number): boolean {
const mark = marks[index]
const other = others[index]
return mark !== undefined && other !== undefined && sameMark(mark, other)
}
function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<AdfNode[]> {
for (let current = leaves; ; ) {
const fallback = plainLineFallback(current, container, path)
if (!fallback.ok) return fallback
if (fallback.value === undefined) return success(current)
const fixed = withoutFallback(current, fallback.value)
if (fixed === undefined) return failure('unsupported-node-shape', 'a plain line keeps a spelling that dropping a mark does not change', path)
current = trimmedEdges(fixed)
}
}
function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback): AdfNode[] | undefined {
if (fallback.kind === 'unspellable-run') return withoutMark(leaves, fallback.run.first, fallback.run.last, fallback.run.depth)
const first = fallback.kind === 'opening-link' ? 0 : lineStart(leaves, fallback.line)
const mark = nodeMarks(leaves[first] ?? {})[0]
if (mark === undefined || mark.type !== (fallback.kind === 'opening-link' ? 'link' : 'code')) return undefined
let last = first
while (sameMarkAt(nodeMarks(leaves[last + 1] ?? {}), [mark], 0)) last += 1
// A code span is what binds the `]` a link definition reads, and dropping it keeps the link target.
const spans = leaves.slice(first, last + 1).some((leaf) => nodeMarks(leaf).length > 1 && nodeMarks(leaf).at(-1)?.type === 'code')
if (mark.type === 'link' && spans) return leaves.map((leaf, index) => (index < first || index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code'))))
return withoutMark(leaves, first, last, 0)
}
function lineStart(leaves: readonly AdfNode[], line: number): number {
let index = 0
for (let breaks = 0; breaks < line && index < leaves.length; index += 1) if (leaves[index]?.type === 'hardBreak') breaks += 1
return index
}
function withoutMark(leaves: readonly AdfNode[], first: number, last: number, depth: number): AdfNode[] {
return leaves.map((leaf, index) => (index < first || index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => held !== depth))))
}
+314
View File
@@ -0,0 +1,314 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import { adfToMarkdown } from './adf-to-markdown.ts'
import { largestNesting } from '../../nesting.ts'
import { reduceToPlain } from './plain-reduction.ts'
const code: AdfMark = { type: 'code' }
const em: AdfMark = { type: 'em' }
const strong: AdfMark = { type: 'strong' }
function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
}
function plain(...content: AdfNode[]): string {
return plainDocument(document(...content))
}
function plainDocument(input: AdfDocument): string {
const reduced = reduceToPlain(input)
if (!reduced.ok) return `${reduced.error.code} at /${reduced.error.path.join('/')}`
const markdown = adfToMarkdown(reduced.value)
if (!markdown.ok) return `emit ${markdown.error.code}: ${markdown.error.message}`
return markdown.value
}
function text(value: string, ...marks: AdfMark[]): AdfNode {
return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' }
}
function node(type: string, attrs: AdfAttributes, ...content: AdfNode[]): AdfNode {
return { attrs, content, type }
}
function paragraph(...content: AdfNode[]): AdfNode {
return { content, type: 'paragraph' }
}
function said(value: string): AdfNode {
return paragraph(text(value))
}
function item(...content: AdfNode[]): AdfNode {
return { content, type: 'listItem' }
}
function bulletList(...content: AdfNode[]): AdfNode {
return { content, type: 'bulletList' }
}
function cell(type: string, ...content: AdfNode[]): AdfNode {
return { content, type }
}
function row(...content: AdfNode[]): AdfNode {
return { content, type: 'tableRow' }
}
function link(href: string, title?: string): AdfMark {
return { attrs: title === undefined ? { href } : { href, title }, type: 'link' }
}
test('refuses what the document guard refuses, and nothing else', () => {
assert.equal(plainDocument({ type: 'doc', version: Number.NaN }), 'not-an-adf-document at /')
assert.equal(plainDocument({ type: 'doc', version: 2 }), 'unsupported-document-version at /')
let deep: AdfNode = said('x')
for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'layoutColumn' }
assert.match(plainDocument(document(deep)), /^unsupported-nesting-depth at \/content\/0(\/content\/0)+$/)
let deepInline: AdfNode = text('x')
for (let level = 0; level <= largestNesting; level += 1) deepInline = { content: [deepInline], type: 'unknownInline' }
assert.match(plainDocument(document(paragraph(deepInline))), /^unsupported-nesting-depth at /)
})
test('refuses nesting past 500 levels wherever the reduction walks', () => {
const lowest = (bottom: AdfNode): string => {
let deep = bottom
for (let level = 0; level < largestNesting; level += 1) deep = { content: [deep], type: 'layoutColumn' }
return plainDocument(document(deep)).split(' ')[0] ?? ''
}
const wrapped: AdfNode = { content: [text('x')], type: 'unknownInline' }
const bottoms: AdfNode[] = [
bulletList(item(said('x'))),
node('taskList', {}, node('taskList', {}, node('taskItem', {}, text('x')))),
node('taskList', {}, node('taskItem', {}, wrapped)),
node('taskList', {}, node('blockTaskItem', {}, said('x'))),
node('panel', {}, said('x')),
node('expand', {}, said('x')),
node('decisionList', {}, node('decisionItem', {}, text('x'))),
node('table', {}, row(cell('tableCell', said('x')))),
node('mediaSingle', {}, node('caption', {}, text('x'))),
node('heading', { level: 1 }, wrapped),
node('codeBlock', {}, wrapped),
paragraph(wrapped),
]
for (const bottom of bottoms) assert.equal(lowest(bottom), 'unsupported-nesting-depth', bottom.type)
})
test('spells a panel as an alert in the GitHub word for its colour', () => {
const panel = (panelType: string | undefined): string =>
plain(node('panel', panelType === undefined ? {} : { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType }, said('Check it.')))
assert.equal(panel('info'), '> [!NOTE]\n>\n> Check it.\n')
assert.equal(panel('note'), '> [!IMPORTANT]\n>\n> Check it.\n')
assert.equal(panel('tip'), '> [!TIP]\n>\n> Check it.\n')
assert.equal(panel('success'), '> [!TIP]\n>\n> Check it.\n')
assert.equal(panel('warning'), '> [!WARNING]\n>\n> Check it.\n')
assert.equal(panel('error'), '> [!CAUTION]\n>\n> Check it.\n')
assert.equal(panel('custom'), '> [!NOTE]\n>\n> Check it.\n')
assert.equal(panel(undefined), '> [!NOTE]\n>\n> Check it.\n')
assert.equal(plain(node('panel', { panelType: 'warning' })), '> [!WARNING]\n')
})
test('spells an expand and a nested expand as a folded callout titled by the marker line', () => {
const nested = node('nestedExpand', { title: 'Inner' }, said('Deep.'))
assert.equal(
plain(node('expand', { localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de', title: 'Build log' }, said('Line.'), nested)),
'> [!NOTE]- Build log\n>\n> Line.\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n',
)
assert.equal(plain(node('expand', {}, said('Line.'))), '> [!NOTE]-\n>\n> Line.\n')
assert.equal(plain(node('expand', { title: ' *Two*\nlines ' })), '> [!NOTE]- \\*Two\\*\\\n> lines\n')
})
test('spells a task list as a bullet list whose items lead with their state', () => {
const task = (state: string, value: string): AdfNode => node('taskItem', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', state }, text(value))
const nested = node('taskList', {}, task('TODO', 'Review'))
assert.equal(plain(node('taskList', {}, task('DONE', 'Write the spec'), nested, task('TODO', 'Ship it'))), '- [x] Write the spec\n - [ ] Review\n- [ ] Ship it\n')
assert.equal(plain(node('taskList', {}, nested, task('DONE', ''), said('Stray'))), '- - [ ] Review\n- [x]\n- Stray\n')
assert.equal(plain(node('taskList', {}), task('TODO', 'Loose')), 'Loose\n')
const blockTask = node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.'))
const codeTask = node('blockTaskItem', { state: 'TODO' }, { content: [text('x')], type: 'codeBlock' })
assert.equal(plain(node('taskList', {}, blockTask, codeTask)), '- [x] First.\n\n Second.\n- [ ]\n\n ```\n x\n ```\n')
})
test('spells a decision list as a plain bullet list', () => {
assert.equal(plain(node('decisionList', {}, node('decisionItem', { state: 'DECIDED' }, text('Ship')), said('Stray'))), '- Ship\n- Stray\n')
})
test('spells a highlight as a == pair around the run, whatever its colour', () => {
const highlight = (color: string): AdfMark => ({ attrs: { color }, type: 'backgroundColor' })
assert.equal(plain(paragraph(text('a '), text('hi', highlight('#fff')), text(' there', highlight('#000')), text(' b'))), 'a ==hi there== b\n')
assert.equal(plain(paragraph(text('hi ', strong, highlight('#fff')), text('b'))), '**==hi==** b\n')
assert.equal(plain(paragraph(text('a', strong, highlight('#fff')), text('b', highlight('#fff'), em))), '==**a**_b_==\n')
assert.equal(plain(paragraph(text('a', highlight('#fff'), code))), '==`a`==\n')
})
test('unwraps the containers plain markdown has no spelling for to their body blocks in order', () => {
const column = (value: string): AdfNode => node('layoutColumn', { width: 50 }, said(value))
assert.equal(plain(node('layoutSection', {}, column('Left.'), column('Right.'))), 'Left.\n\nRight.\n')
assert.equal(plain(node('bodiedExtension', { extensionKey: 'k' }, said('Body.'))), 'Body.\n')
assert.equal(plain(node('bodiedSyncBlock', { resourceId: 'r' }, said('Synced.'))), 'Synced.\n')
const frame = (value: string): AdfNode => node('extensionFrame', {}, said(value))
assert.equal(plain(node('multiBodiedExtension', { extensionKey: 'k' }, frame('One.'), frame('Two.'))), 'One.\n\nTwo.\n')
})
test('keeps the CommonMark blocks in their spelling and drops their attributes and marks', () => {
const localId = { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05' }
assert.equal(plain(node('paragraph', localId, text('x')), node('heading', { level: 2, localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de' }, text('h'))), 'x\n\n## h\n')
assert.equal(plain({ attrs: localId, content: [said('q')], marks: [{ type: 'breakout' }], type: 'blockquote' }), '> q\n')
assert.equal(plain(node('codeBlock', { language: 'ts', wrap: true }, text('a\r\nb\u0000'))), '```ts\na\nb\n```\n')
assert.equal(plain(node('codeBlock', { language: 'carry' }, text('a'), { type: 'hardBreak' }, text('b', strong))), '```\na\nb\n```\n')
assert.equal(plain(node('codeBlock', {})), '```\n```\n')
assert.equal(plain(node('rule', { color: '#000' })), '---\n')
assert.equal(plain(node('orderedList', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', order: 3 }, item(said('c')))), '3. c\n')
assert.equal(plain(node('orderedList', {}, item(said('a')))), '1. a\n')
assert.equal(plain(node('orderedList', { order: -1 }, item(said('a')))), '1. a\n')
const code: AdfNode = { content: [text('x')], type: 'codeBlock' }
assert.equal(plain(node('orderedList', { order: 1e10 }, item(said('Alpha')), item(code), item())), '- 10000000000. Alpha\n- 10000000001.\n\n ```\n x\n ```\n- 10000000002.\n')
const long = node('orderedList', { order: 1e10 }, item(said('y')))
assert.equal(plain(bulletList(item(said('x'))), long, bulletList(item(said('z')))), '- x\n- 10000000000. y\n- z\n')
assert.equal(plain(node('taskList', {}, node('taskItem', { state: 'DONE' }, text('t'))), long), '- [x] t\n- 10000000000. y\n')
assert.equal(plain(bulletList(item(said('a'), bulletList(item(said('x'))), long))), '- a\n - x\n - 10000000000. y\n')
const givesWay = node('orderedList', { order: 1e10 }, item(node('rule', {})), item(bulletList(item(bulletList(item())))))
assert.equal(plain(givesWay, bulletList(item(said('z')))), '- 10000000000.\n- 10000000001.\n - -\n- z\n')
assert.equal(plain(node('orderedList', { order: 999999999 }, item(said('a'))), node('orderedList', { order: 5 }, item(said('b')))), '- 999999999\\. a\n- 5\\. b\n')
assert.equal(plain(node('heading', { level: 7 }, text('h'))), 'h\n')
})
test('spells an inline node as its text', () => {
assert.equal(plain(paragraph(node('mention', { id: 'a', text: '@Mikael' }), text(' and '), node('status', { color: 'red', text: 'Blocked' }))), '@Mikael and Blocked\n')
assert.equal(plain(paragraph(node('emoji', { shortName: ':tada:', text: '🎉' }), node('emoji', { shortName: ':smile:' }))), '🎉:smile:\n')
assert.equal(plain(paragraph(node('date', { timestamp: '1757721600000' }), text(' '), node('date', { timestamp: 'soon' }))), '2025-09-13\n')
assert.equal(plain(paragraph({ marks: [strong], ...node('mention', { text: '@Mikael' }) })), '**@Mikael**\n')
assert.equal(plain(paragraph(text('by '), node('mention', { id: '5b10a2' }), node('mention', {}))), 'by @5b10a2\n')
})
test('spells a card as a link to its url, or to its data url named by its data name, else a note', () => {
assert.equal(plain(paragraph(node('inlineCard', { url: 'https://example.com' }))), '<https://example.com>\n')
assert.equal(plain(paragraph({ ...node('inlineCard', { url: 'https://example.com' }), marks: [strong, link('https://other.com')] })), '**<https://example.com>**\n')
assert.equal(plain(paragraph(text('see '), node('inlineCard', { data: {} }))), 'see _(link card not included)_\n')
assert.equal(plain(paragraph(node('inlineCard', { data: { name: 'Spec', url: 'https://e.com/s' } }), text(' '), node('inlineCard', { data: { url: 'https://e.com/u' } }))), '[Spec](https://e.com/s) <https://e.com/u>\n')
assert.equal(plain(paragraph(node('inlineCard', { data: { name: 'Spec' } }), text(' '), node('inlineCard', { data: ['x'] }))), 'Spec _(link card not included)_\n')
assert.equal(plain(node('blockCard', { url: 'https://example.com/a b' })), '[https://example.com/a b](<https://example.com/a b>)\n')
assert.equal(plain(node('embedCard', { layout: 'center', url: 'https://example.com' })), '<https://example.com>\n')
assert.equal(plain(node('blockCard', { data: {} })), '_(link card not included)_\n')
})
test('keeps an external image wherever it stands and spells a stored file as its alt text, else a note', () => {
const media = (attrs: AdfAttributes): AdfNode => ({ attrs, type: 'media' })
const caption: AdfNode = node('caption', {}, text('The moon.'))
const external = media({ alt: 'Moon', height: 10, type: 'external', url: 'https://example.com/moon.png' })
assert.equal(plain(node('mediaSingle', { layout: 'wide', width: 50 }, external, caption)), '![Moon](https://example.com/moon.png)\n\nThe moon.\n')
assert.equal(plain(node('mediaSingle', {}, media({ alt: '', type: 'external', url: 'https://example.com/a.png' }))), '![](https://example.com/a.png)\n')
assert.equal(plain(node('mediaSingle', {}, media({ alt: ' Two\nlines ', type: 'external', url: 'u' }), media({ type: 'external', url: 'v' }))), '![Two lines](u)\n\n![](v)\n')
assert.equal(plain(node('mediaSingle', {}, media({ alt: 'Bad', type: 'external', url: 'a\\b <&amp;>' }))), '![Bad](<a%5Cb %3C%26amp;%3E>)\n')
assert.equal(plain(node('mediaSingle', {}, media({ alt: 'Photo', collection: 'c', id: 'i', type: 'file' }))), 'Photo\n')
assert.equal(plain(node('mediaGroup', {}, media({ alt: 'One', type: 'file' }), media({ type: 'file' }), external)), 'One\n\n_(image not included)_\n\n![Moon](https://example.com/moon.png)\n')
assert.equal(plain(external), '![Moon](https://example.com/moon.png)\n')
assert.equal(plain(paragraph(text('a '), node('mediaInline', { alt: 'clip', type: 'file' }), text(' '), node('mediaInline', { type: 'file' }))), 'a clip _(image not included)_\n')
assert.equal(plain(paragraph(text('See '), external, text(' for '), node('mediaInline', { type: 'external', url: 'https://e.com/i.png' }))), 'See [Moon](https://example.com/moon.png) for <https://e.com/i.png>\n')
assert.equal(plain(caption), 'The moon.\n')
})
test('spells an extension as its text attribute, else a note naming it, and a placeholder as nothing', () => {
assert.equal(plain(node('extension', { extensionKey: 'toc', text: 'Contents' }), node('extension', { extensionKey: 'jira-issues-table' })), 'Contents\n\n_(jira-issues-table not included)_\n')
assert.equal(plain(node('syncBlock', { resourceId: 'r' })), '_(synced block not included)_\n')
assert.equal(plain(node('extension', { extensionKey: 'jira\r\nissues\u0000' })), '_(jira issues not included)_\n')
assert.equal(plain(node('extension', { extensionKey: '\r\u0000' }), node('extension', { extensionKey: '\n' })), '_(extension not included)_\n\n_(extension not included)_\n')
const blank = paragraph(node('inlineExtension', { extensionKey: 'k', text: '\r' }), text(' '), node('mediaInline', { alt: '\u0000', type: 'file' }), text(' '), node('mention', { id: '5b10a2', text: '\r' }))
assert.equal(plain(blank), '_(k not included)_ _(image not included)_ @5b10a2\n')
assert.equal(plain(paragraph(text('a '), node('inlineExtension', { text: 'macro' }), text(' '), node('inlineExtension', {}), node('placeholder', { text: 'Type here' }))), 'a macro _(extension not included)_\n')
})
test('spells a node no row names, or one standing where no spelling holds it, as its blocks or its text', () => {
assert.equal(plain(node('futureBlock', {}, said('Inside.'))), 'Inside.\n')
assert.equal(plain(paragraph(text('a '), { content: [text('b')], text: 'c', type: 'futureInline' })), 'a c b\n')
assert.equal(plain(text('loose'), node('mention', { text: '@x' }), node('listItem', {}, said('item'))), 'loose@x\n\nitem\n')
assert.equal(plain(paragraph(text('a '), node('bulletList', {}, item(said('b')), item(said('c'))))), 'a b c\n')
assert.equal(plain(bulletList(said('stray'), item(said('b')), text('loose'))), '- stray\n- b\n- loose\n')
assert.equal(plain(bulletList()), '')
assert.equal(plain(bulletList(item({ content: [text('a\n \nb')], type: 'codeBlock' }))), '- ```\n a\n\n b\n ```\n')
assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {}), said('Install')), item(said('Configure')))), '- Install\n- Configure\n')
assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {})), item(said('Configure')))), '-\n- Configure\n')
assert.equal(plain(bulletList(item(bulletList(item(bulletList(item())))))), '- -\n')
assert.equal(plain(node('nestedExpand', {}, node('tableCell', {}, said('c')))), '> [!NOTE]-\n>\n> c\n')
})
test('keeps a table as a pipe table headed by its first row, one line per cell', () => {
const table = node(
'table',
{ layout: 'wide' },
row(cell('tableCell', said('Part')), cell('tableCell', said('Qty'))),
row(cell('tableHeader', said('Bolt'), bulletList(item(said('M8')))), { attrs: { background: '#fff' }, content: [paragraph(text('4'), { type: 'hardBreak' }, text('0'))], type: 'tableCell' }),
)
assert.equal(plain(table), '| Part | Qty |\n| --- | --- |\n| Bolt M8 | 4 0 |\n')
const spanned = node(
'table',
{},
row(cell('tableHeader', said('A')), cell('tableHeader', said('B')), cell('tableHeader', said('C'))),
row(node('tableCell', { colspan: 2, rowspan: 2 }, said('wide')), cell('tableCell', said('c'))),
row(cell('tableCell', said('d'))),
row(cell('tableCell')),
)
assert.equal(plain(spanned), '| A | B | C |\n| --- | --- | --- |\n| wide | | c |\n| | | d |\n| | | |\n')
const huge = node('table', {}, row(node('tableHeader', { colspan: 1e9, rowspan: 1e9 }, said('A')), cell('tableHeader', said('B'))), row(cell('tableCell', said('c'))))
assert.equal(plain(huge), '| A | | | | B |\n| --- | --- | --- | --- | --- |\n| c | | | | |\n')
assert.equal(plain(node('table', {}, row(cell('tableHeader', paragraph(text('a|b', code), text(' '), text('x', link('https://e.com/|'))))))), '| a\\|b [x](https://e.com/%7C) |\n| --- |\n')
assert.equal(plain(node('table', {}, said('stray'))), '| stray |\n| --- |\n')
const titled = paragraph(text('t', link('https://e.com', 'a|b')))
const folded = [node('expand', { title: 'Log' }, said('x')), node('nestedExpand', {}, said('y'))]
assert.equal(plain(node('table', {}, row(cell('tableHeader', titled), cell('tableHeader', ...folded)))), '| [t](https://e.com) | Log x y |\n| --- | --- |\n')
assert.equal(plain(node('table', {}, row())), '')
})
test('keeps code, em, link, strike and strong and drops every other mark, keeping its text', () => {
const marks: AdfMark[] = [{ type: 'strike' }, { attrs: { type: 'sub' }, type: 'subsup' }, { type: 'underline' }, { attrs: { color: '#f00' }, type: 'textColor' }]
assert.equal(plain(paragraph(text('H'), text('2', ...marks), text('O', em, strong), text('!', { attrs: { size: 1 }, type: 'border' }))), 'H~~2~~_**O**_!\n')
assert.equal(plain(paragraph(text('x', code, strong), text(' '), text('y', { attrs: { x: 1 }, type: 'strong' }), text('z', em, em))), '**`x`** **y**_z_\n')
assert.equal(plain(paragraph(text('site', { attrs: { collection: 'c', href: 'https://e.com', id: 'i' }, type: 'link' }))), '[site](https://e.com)\n')
})
test('percent-encodes a link href no CommonMark escape writes until one does', () => {
assert.equal(plain(paragraph(text('a', link('a\\b')), text(' '), text('b', { attrs: { id: 'i' }, type: 'link' }), text(' '), text('c', link('/&amp;')))), '[a](a%5Cb) b [c](/%26amp;)\n')
assert.equal(plain(paragraph(text('t', link('https://e.com', 'two\nlines')), text(' '), text('u', link('https://e.com', 'a\\b')))), '[t](https://e.com "two lines") [u](https://e.com)\n')
assert.equal(plain(paragraph(text(']: a', link('/u'), code))), '[\\]: a](/u)\n')
})
test('drops the mark of a run CommonMark flanking or matching cannot spell', () => {
assert.equal(plain(paragraph(text('un'), text('-real', strong), text('istic'))), 'un-realistic\n')
assert.equal(plain(paragraph(text('a', em), text('b', strong), text('c', em))), '_a_**b**_c_\n')
assert.equal(plain(paragraph(text('x'), text('*', em), text('y'))), 'x\\*y\n')
})
test('breaks a line at a newline and trims whitespace at every edge CommonMark strips', () => {
assert.equal(plain(paragraph(text(' \n a \n b\n'))), 'a\\\nb\n')
assert.equal(plain(paragraph({ type: 'hardBreak' }, text('a'), { attrs: { text: '\n' }, type: 'hardBreak' }, text('b'), { type: 'hardBreak' })), 'a\\\nb\n')
assert.equal(plain(paragraph(text('a'), text(' b ', strong), text('c'))), 'a **b** c\n')
assert.equal(plain(paragraph(text('a'), text(' b ', em, strong), text(' ', em), text('c', em))), 'a _**b** c_\n')
assert.equal(plain(paragraph(text(' x ', code))), '` x `\n')
assert.equal(plain(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n')
assert.equal(plain(paragraph(text('a\r\u0000b'))), 'ab\n')
})
test('drops the code mark of a span opening a line with backticks that read as a fence', () => {
assert.equal(plain(paragraph(text('``` x', code))), '\\`\\`\\` x\n')
assert.equal(plain(paragraph(text('a\n'), text('``` x', code))), 'a\\\n\\`\\`\\` x\n')
assert.equal(plain(paragraph(text('a '), text('``` x', code))), 'a ```` ``` x ````\n')
})
test('drops an empty paragraph and merges adjacent lists of one type', () => {
const ordered = (order: number, value: string): AdfNode => node('orderedList', { order }, item(said(value)))
assert.equal(plain(said('a'), paragraph(), paragraph(text(' ')), said('b')), 'a\n\nb\n')
assert.equal(plain(bulletList(item(said('a'))), paragraph(), node('decisionList', {}, node('decisionItem', {}, text('b')))), '- a\n- b\n')
assert.equal(plain(ordered(2, 'a'), ordered(3, 'b'), bulletList(item(said('c')))), '2. a\n3. b\n\n- c\n')
assert.equal(plain(bulletList(item(said('x'))), ordered(1, 'a'), ordered(5, 'b'), ordered(6, 'c')), '- x\n- 1\\. a\n- 5\\. b\n\n6. c\n')
assert.equal(plain(ordered(1, 'a'), ordered(1e10, 'y')), '- 1\\. a\n- 10000000000. y\n')
const column = (list: AdfNode): AdfNode => node('layoutColumn', {}, list)
assert.equal(plain(node('layoutSection', {}, column(bulletList(item(said('a')))), column(bulletList(item(said('b')))))), '- a\n- b\n')
})
test('returns a document the lossless emitter spells without a directive', () => {
const reduced = reduceToPlain(document(node('panel', { panelType: 'info' }, said('x'))))
assert.deepEqual(reduced.ok ? reduced.value : undefined, document({ content: [said('[!NOTE]'), said('x')], type: 'blockquote' }))
})
+345
View File
@@ -0,0 +1,345 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { commonMarkSpelling, largestListMarker, type SpellingMemo } from './adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './plain-inline.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
// depth: the level the node reduced stands at, counted as the emitter counts it.
type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
type BlockReducer = (node: AdfNode, reduction: Reduction) => Result<AdfNode[]>
type PlacedCell = { colspan: number; paragraph: AdfNode; rowspan: number }
type Placed = { index: number; loose: AdfNode[] } | { index: number; loose?: undefined; node: AdfNode }
const alertWords: Readonly<Record<string, string>> = {
error: 'CAUTION',
info: 'NOTE',
note: 'IMPORTANT',
success: 'TIP',
tip: 'TIP',
warning: 'WARNING',
}
const blockReducers: Readonly<Record<string, BlockReducer>> = {
blockCard: paragraphOfNode,
blockquote: (node, reduction) => quoted(success([]), node, reduction),
bulletList: reduceList,
caption: (node, reduction) => paragraphOf(nodeContent(node), reduction),
codeBlock: reduceCodeBlock,
decisionList: (node, reduction) => reduceItems(node, reduction, (child, at) => (child.type === 'decisionItem' ? paragraphOf(nodeContent(child), at) : reduceStanding(child, at))),
embedCard: paragraphOfNode,
expand: reduceExpand,
extension: paragraphOfNode,
heading: reduceHeading,
media: reduceMedia,
mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index) => reduceStanding(child, childReduction(reduction, index)))),
nestedExpand: reduceExpand,
orderedList: reduceList,
panel: (node, reduction) => quoted(success([paragraph([text(`[!${alertWord(nodeAttrs(node)['panelType'])}]`)])]), node, reduction),
paragraph: (node, reduction) => paragraphOf(nodeContent(node), reduction),
rule: () => success([{ type: 'rule' }]),
syncBlock: paragraphOfNode,
table: reduceTable,
taskList: reduceTaskList,
}
export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const blocks = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
return blocks.ok ? success({ content: blocks.value, type: 'doc', version: 1 }) : blocks
}
function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
const placed: Placed[] = []
for (const [index, node] of nodes.entries()) {
const previous = placed[placed.length - 1]
if (!standsInline(node)) placed.push({ index, node })
else if (previous?.loose !== undefined) previous.loose.push(node)
else placed.push({ index, loose: [node] })
}
const blocks = concatenated(
placed.map((entry) => {
const at = { ...reduction, path: [...reduction.path, 'content', entry.index] }
return entry.loose === undefined ? reduceNode(entry.node, at) : paragraphOf(entry.loose, reduction)
}),
)
return blocks.ok ? plainSequence(blocks.value, reduction) : blocks
}
function reduceNode(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
if (reduction.depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, reduction.path)
const reducer = Object.hasOwn(blockReducers, node.type) ? blockReducers[node.type] : undefined
return (reducer ?? reduceBody)(node, reduction)
}
function reduceStanding(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
return standsInline(node) ? paragraphOf([node], reduction) : reduceNode(node, reduction)
}
function standsInline(node: AdfNode): boolean {
if (node.type === 'text' || inlineNodeModel(node.type) !== undefined) return true
return !isBlockNodeType(node.type) && node.content === undefined
}
function reduceBody(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
if (blockNodeModel(node.type)?.contentModel === 'inline') return paragraphOf(nodeContent(node), reduction)
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 concatenated(results: readonly Result<AdfNode[]>[]): Result<AdfNode[]> {
const blocks: AdfNode[] = []
for (const result of results) {
if (!result.ok) return result
for (const block of result.value) blocks.push(block)
}
return success(blocks)
}
// A list still taking the directive form gives way to its items' blocks.
function plainSequence(blocks: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
let sequence = mergedLists(blocks.filter((block) => block.type !== 'paragraph' || nodeContent(block).length > 0))
for (let index = 0; index < sequence.length; index += 1) {
const listed = sequence[index]
if (listed === undefined || (listed.type !== 'bulletList' && listed.type !== 'orderedList')) continue
const block = numberedPastMarkers(listed)
const spelled = block === listed && commonMarkSpelling(block, reduction.path, reduction.depth, reduction.memo)?.ok === true
if (spelled) continue
sequence = spliced(sequence, index, block === listed ? nodeContent(block).flatMap(nodeContent) : [block])
index = Math.max(0, index - 1) - 1
}
return success(sequence)
}
// The replacement merges with the lists beside it, so no two lists of one type stand adjacent.
function spliced(sequence: readonly AdfNode[], index: number, replacement: readonly AdfNode[]): AdfNode[] {
const from = Math.max(0, index - 1)
return [...sequence.slice(0, from), ...mergedLists([...sequence.slice(from, index), ...replacement, ...sequence.slice(index + 1, index + 2)]), ...sequence.slice(index + 2)]
}
// A numbered list whose markers run past CommonMark's keeps its numbers as text in a bullet list.
function numberedPastMarkers(list: AdfNode): AdfNode {
const order = nodeAttrs(list)['order']
if (list.type !== 'orderedList' || typeof order !== 'number' || order + nodeContent(list).length - 1 <= largestListMarker) return list
return numberedAsText(list)
}
function numberedAsText(list: AdfNode): AdfNode {
const order = Number(nodeAttrs(list)['order'])
return { content: nodeContent(list).map((item, offset) => itemOf(marked(nodeContent(item), `${order + offset}.`))), type: 'bulletList' }
}
function mergedLists(blocks: readonly AdfNode[]): AdfNode[] {
const merged: AdfNode[] = []
for (const block of blocks) {
let next = block
for (let previous = merged.at(-1); previous !== undefined && previous.type === next.type && isList(next); previous = merged.at(-1)) {
merged.pop()
next = joinedLists(previous, next)
}
merged.push(next)
}
return merged
}
function isList(block: AdfNode): boolean {
return block.type === 'bulletList' || block.type === 'orderedList'
}
// Two numbered lists whose numbering breaks between them keep their numbers as text in one bullet list.
function joinedLists(first: AdfNode, second: AdfNode): AdfNode {
const breaks = first.type === 'orderedList' && nodeAttrs(second)['order'] !== Number(nodeAttrs(first)['order']) + nodeContent(first).length
const [head, tail] = breaks ? [numberedAsText(first), numberedAsText(second)] : [first, second]
return { ...head, content: [...nodeContent(head), ...nodeContent(tail)] }
}
function paragraph(content: readonly AdfNode[]): AdfNode {
return { content: [...content], type: 'paragraph' }
}
function text(value: string): AdfNode {
return { text: value, type: 'text' }
}
function listOf(items: readonly AdfNode[], type: string): AdfNode[] {
return items.length === 0 ? [] : [{ content: [...items], type }]
}
function paragraphOf(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
const content = reduceInline(nodes, 'paragraph', reduction.path, reduction.depth)
return content.ok ? success(content.value.length === 0 ? [] : [paragraph(content.value)]) : content
}
function paragraphOfNode(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
return paragraphOf([node], reduction)
}
function quoted(head: Result<AdfNode[]>, node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const content = concatenated([head, reduceBlocks(nodeContent(node), { ...reduction, depth: reduction.depth + 1 })])
return content.ok ? success([{ content: content.value, type: 'blockquote' }]) : content
}
function alertWord(panelType: unknown): string {
const word = typeof panelType === 'string' && Object.hasOwn(alertWords, panelType) ? alertWords[panelType] : undefined
return word ?? 'NOTE'
}
function reduceExpand(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const title = nodeAttrs(node)['title']
const marker = typeof title === 'string' ? `[!NOTE]- ${title.replace(/^[ \t\n\r]+/, '')}` : '[!NOTE]-'
return quoted(paragraphOf([text(marker)], { ...reduction, depth: reduction.depth + 1 }), node, reduction)
}
function reduceHeading(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
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)
return content.ok ? success([{ attrs: { level }, content: content.value, type: 'heading' }]) : content
}
function reduceCodeBlock(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const leaves = inlineLeaves(nodeContent(node), 'paragraph', reduction.path, reduction.depth)
if (!leaves.ok) return leaves
const code = leaves.value.map((leaf) => leaf.text ?? '\n').join('')
const slot = languageSlot(nodeAttrs(node)['language'])
const block: AdfNode = { content: code === '' ? [] : [text(code)], type: 'codeBlock' }
return success([slot.kind === 'fence' ? { ...block, attrs: { language: slot.info } } : block])
}
function reduceList(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const listed = reduceItems(node, reduction, (child, at) => (child.type === 'listItem' ? reduceBlocks(nodeContent(child), at) : reduceStanding(child, at)))
if (!listed.ok || node.type !== 'orderedList') return listed
const order = nodeAttrs(node)['order']
const start = typeof order === 'number' && Number.isInteger(order) && order >= 0 ? order : 1
return success(listed.value.map((list) => ({ ...list, attrs: { order: start } })))
}
function reduceItems(node: AdfNode, reduction: Reduction, itemBlocks: (child: AdfNode, at: Reduction) => Result<AdfNode[]>): Result<AdfNode[]> {
const items = concatenated(nodeContent(node).map((child, index) => listItem(itemBlocks(child, childReduction(reduction, index)))))
return items.ok ? success(listOf(items.value, node.type === 'orderedList' ? 'orderedList' : 'bulletList')) : items
}
function listItem(blocks: Result<AdfNode[]>): Result<AdfNode[]> {
return blocks.ok ? success([itemOf(blocks.value)]) : blocks
}
// 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 {
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' }
}
function blankedLines(code: AdfNode): AdfNode {
return code.text === undefined ? code : { ...code, text: code.text.replace(/^[ \t]+$/gm, '') }
}
function reduceTaskList(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const items: AdfNode[] = []
for (const [index, child] of nodeContent(node).entries()) {
const blocks = taskBlocks(child, childReduction(reduction, index))
if (!blocks.ok) return blocks
const previous = child.type === 'taskList' ? items.pop() : undefined
items.push(itemOf(previous === undefined ? blocks.value : mergedLists([...nodeContent(previous), ...blocks.value])))
}
return success(listOf(items, 'bulletList'))
}
function taskBlocks(child: AdfNode, at: Reduction): Result<AdfNode[]> {
const marker = nodeAttrs(child)['state'] === 'DONE' ? '[x]' : '[ ]'
if (child.type === 'taskItem') {
const content = reduceInline(nodeContent(child), 'paragraph', at.path, at.depth)
return content.ok ? success([paragraph(content.value.length === 0 ? [text(marker)] : [text(`${marker} `), ...content.value])]) : content
}
if (child.type !== 'blockTaskItem') return reduceStanding(child, at)
const blocks = reduceBlocks(nodeContent(child), at)
return blocks.ok ? success(marked(blocks.value, marker)) : blocks
}
// 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[]> {
const rows: PlacedCell[][] = []
for (const [rowIndex, row] of nodeContent(node).entries()) {
const rowReduction = childReduction(reduction, rowIndex)
const cells: PlacedCell[] = []
for (const [cellIndex, cell] of (row.type === 'tableRow' ? nodeContent(row) : [row]).entries()) {
const paragraph = cellParagraph(cell, childReduction(rowReduction, cellIndex))
if (!paragraph.ok) return paragraph
cells.push({ colspan: span(nodeAttrs(cell)['colspan']), paragraph: paragraph.value, rowspan: span(nodeAttrs(cell)['rowspan']) })
}
rows.push(cells)
}
const grid = spannedGrid(rows)
const width = grid.reduce((widest, cells) => Math.max(widest, cells.length), 0)
const tableRows = grid.map((cells, rowIndex) => ({
content: Array.from({ length: width }, (_, column): AdfNode => ({ content: [cells[column] ?? { type: 'paragraph' }], type: rowIndex === 0 ? 'tableHeader' : 'tableCell' })),
type: 'tableRow',
}))
return success(width === 0 ? [] : [{ content: tableRows, type: 'table' }])
}
function span(value: unknown): number {
return typeof value === 'number' && Number.isInteger(value) && value > 1 ? value : 1
}
// A span keeps its cell under its header by empty cells where it covered; they number no more than the table's cells.
function spannedGrid(rows: readonly PlacedCell[][]): (AdfNode | undefined)[][] {
const grid: (AdfNode | undefined)[][] = rows.map(() => [])
const covered = rows.map(() => new Set<number>())
let padding = rows.reduce((count, cells) => count + cells.length, 0)
for (const [rowIndex, cells] of rows.entries()) {
let column = 0
for (const cell of cells) {
while (covered[rowIndex]?.has(column) === true) column += 1
setCell(grid, rowIndex, column, cell.paragraph)
for (let row = rowIndex; row < Math.min(rows.length, rowIndex + cell.rowspan) && padding > 0; row += 1) {
for (let spanned = row === rowIndex ? 1 : 0; spanned < cell.colspan && padding > 0; spanned += 1) {
covered[row]?.add(column + spanned)
setCell(grid, row, column + spanned, { type: 'paragraph' })
padding -= 1
}
}
column += 1
}
}
return grid
}
function setCell(grid: (AdfNode | undefined)[][], row: number, column: number, cell: AdfNode): void {
const cells = grid[row]
if (cells !== undefined && cells[column] === undefined) cells[column] = cell
}
function cellParagraph(cell: AdfNode, reduction: Reduction): Result<AdfNode> {
const blocks = cell.type === 'tableCell' || cell.type === 'tableHeader' ? nodeContent(cell) : [cell]
const content = reduceInline(blocks, 'table-cell', reduction.path, reduction.depth)
return content.ok ? success(content.value.length === 0 ? { type: 'paragraph' } : paragraph(content.value)) : content
}
function reduceMedia(media: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const attrs = nodeAttrs(media)
const url = attrs['url']
if (attrs['type'] !== 'external' || typeof url !== 'string') return paragraphOfNode(media, reduction)
const held = attrs['alt']
const alt = typeof held === 'string' ? oneLine(held).trim() : ''
const external: AdfNode = { attrs: alt === '' ? { type: 'external', url: writableHref(url) } : { alt, type: 'external', url: writableHref(url) }, type: 'media' }
const image: AdfNode = { attrs: { layout: 'center' }, content: [external], type: 'mediaSingle' }
return success([image])
}
+3
View File
@@ -1071,6 +1071,9 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li
nothing on that path reads a block's `headroom`: `joinBlocks` and `separationBetween` read
`text` and `spelling`, and `emitDirectiveBlock` takes the level from the `Walk`. Of the two
subtractions three readers flagged as double-counting, this is the one that is dead.
- [ ] **10 — Lossy conversion (`0.2.0`).**
- [x] **10a — The reduction.** `adfToPlainMarkdown`'s ADF→ADF reduction, tests first, a test per
row above.
## 5 — Ship `0.1.0`
+15 -10
View File
@@ -15,8 +15,7 @@ Start a session with: `Read AGENTS.md and todo.md, then do what todo.md's "Next
states, which wins over where an item's bullet sits: a newly filed item is written beside the
one it came in with, not at its own place in the order. Where that item has no release, the
planning chunk §15 describes.
3. In flight: 10a on the local branch `10a` (worktree `../adf-codec-10a`, `8a3a83f`), built before
item 10's rows were rewritten on 2026-09-25; rework it against them.
3. In flight: nothing.
4. Before stopping, rewrite this section: the in-flight line, and the prompt itself wherever the
session found it wrong or short.
@@ -61,7 +60,8 @@ chunk clearing a §11 seam.
the measured one.
- [ ] **33 — A carried mark run costs the line one re-emit (`0.2.0`).** `adfToMarkdown` spends 23 s
on one paragraph of 2000 × `un` plus `**-r**`: each run its flanking cannot spell re-emits the
whole line before riding the carry, quadratic in the runs (§11 Bounds). Make it linear.
whole line before riding the carry, quadratic in the runs (§11 Bounds), and the plain
reduction's `spellableLine` drops one mark per re-emit the same way. Make both linear.
- [x] **24 — The conformance gates have a directory (`0.2.0`).**
- [x] **25 — AGENTS.md §8 and §11 are findable (`0.2.0`).**
- [x] **26 — The two mutable structures say what they guarantee (`0.2.0`).**
@@ -220,19 +220,25 @@ chunk clearing a §11 seam.
- `mention` and `status` become their text, the mention's `@` kept; `emoji` its text or else its
`shortName`; `date` its ISO date in UTC (`2026-09-13`); `inlineCard`, `blockCard` and
`embedCard` a link to their `url`, or to their `data`'s `url` named by its `name` — the name
alone without a `url`; an external image, wherever it stands, `![alt](url)`; `media`,
alone without a `url`; an external image `![alt](url)` in a block and `[alt](url)` inline, where no ADF node spelled
`![alt](url)` stands (a panel, 6 of 7, 2026-09-25); `media`,
`mediaGroup` and `mediaInline` holding a stored file their `alt` text; `caption` its text as
a paragraph; `extension` and `inlineExtension` their `text` attribute; `placeholder` nothing,
its text being the editor's prompt rather than the document's; a node no row names, or one
standing where no spelling holds it, its blocks or its text.
- Content the document only references — a stored file with no `alt`, an extension with no
`text`, a `syncBlock`, a card with neither `url` nor `data` naming one — leaves an italic note
naming it: `*(image not included)*`, `*(jira-issues-table not included)*`.
naming it: `_(image not included)_`, `_(jira-issues-table not included)_`, `_(synced block not
included)_`, `_(link card not included)_`, `_(extension not included)_` without a key; a mention
with no text is `@` and its id (panels, 3 of 3, 2026-09-25).
- A table stays a pipe table: the first row becomes the header, a cell's blocks join on one line
with spaces, and a span keeps its cell under its header by empty cells in the columns and
rows it covered, padding at most to the table's cell count.
- A list stays a list: where CommonMark cannot hold a block inside an item, what gives way is
what a reader does not see — the spaces of a whitespace-only code line, a rule's spelling.
what a reader does not see — the spaces of a whitespace-only code line — and a rule opening an
item drops (a panel, 3 of 3, 2026-09-25); an ordered list running past `999999999`, or adjacent
ordered lists whose numbering does not continue, is one bullet list keeping its numbers as text
(panels, 3 of 3 and 5 of 7, 2026-09-25).
- `code`, `em`, `link`, `strike` and `strong` stay and every other mark drops, keeping its text —
`subsup` too, since `~2~` is a strike on GitHub; a link no CommonMark escape writes has its
`href` percent-encoded until one does, and a mark run CommonMark's flanking or matching cannot
@@ -245,13 +251,12 @@ chunk clearing a §11 seam.
(`<details>`, `<mark>`), MkDocs `!!!` and the `:::` admonition family, footnotes, definition
lists, wikilinks, embeds, tags, comments, TOC tokens, spoilers, task states past `[x]`/`[ ]`,
and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes.
- [ ] **10a — The reduction.** `adfToPlainMarkdown`'s ADF→ADF reduction, tests first, a test per
row above.
- [x] **10a — The reduction.**
- [ ] **10b — The lift.** `plainMarkdownToAdf`'s ADF→ADF lift, tests first, a test per row it reads,
other tools' spellings included; the editor's default highlight colour looked up and cited.
- [ ] **10c — The exports.** `adfToPlainMarkdown` and `plainMarkdownToAdf` exported with their README
sections, and two properties over 4.2's generators: writing refuses only the guard's codes,
and markdown `adfToPlainMarkdown` wrote reads back through `plainMarkdownToAdf` and writes
sections, and two properties over 4.2's generators: writing refuses only the guard's codes
and writes no `!adf:`, and markdown `adfToPlainMarkdown` wrote reads back through `plainMarkdownToAdf` and writes
again byte for byte. AGENTS.md §1 records the pair as composed around the lossless one.
- [x] **11 — Atlassian's ADF schema as the tables' truth.**
- [x] **11a — The vendored schema.**