diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 49a461d..fda1bb9 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -24,7 +24,7 @@ export type SpellingMemo = Map 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, '') diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 90be255..796c8e1 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -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; 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 { 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 { + 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 { + 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 }, ], }) } diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 69b73af..34e000f 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -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) { diff --git a/src/markdown/emit/plain-inline.ts b/src/markdown/emit/plain-inline.ts new file mode 100644 index 0000000..f4f3b09 --- /dev/null +++ b/src/markdown/emit/plain-inline.ts @@ -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 { + 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 { + 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 { + 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, 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, 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 { + 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)))) +} diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts new file mode 100644 index 0000000..6aafd56 --- /dev/null +++ b/src/markdown/emit/plain-reduction.test.ts @@ -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' }))), '\n') + assert.equal(plain(paragraph({ ...node('inlineCard', { url: 'https://example.com' }), marks: [strong, link('https://other.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) \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]()\n') + assert.equal(plain(node('embedCard', { layout: 'center', url: '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 <&>' }))), '![Bad]()\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 \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('/&')))), '[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' })) +}) diff --git a/src/markdown/emit/plain-reduction.ts b/src/markdown/emit/plain-reduction.ts new file mode 100644 index 0000000..a66be46 --- /dev/null +++ b/src/markdown/emit/plain-reduction.ts @@ -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 + +type PlacedCell = { colspan: number; paragraph: AdfNode; rowspan: number } + +type Placed = { index: number; loose: AdfNode[] } | { index: number; loose?: undefined; node: AdfNode } + +const alertWords: Readonly> = { + error: 'CAUTION', + info: 'NOTE', + note: 'IMPORTANT', + success: 'TIP', + tip: 'TIP', + warning: 'WARNING', +} + +const blockReducers: Readonly> = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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[]): Result { + 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 { + 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 { + 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 { + return paragraphOf([node], reduction) +} + +function quoted(head: Result, node: AdfNode, reduction: Reduction): Result { + 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 { + 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 { + 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 { + 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 { + 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): Result { + 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): Result { + 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 { + 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 { + 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 { + 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()) + 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 { + 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 { + 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]) +} diff --git a/todo-history.md b/todo-history.md index d110e33..26e6af4 100644 --- a/todo-history.md +++ b/todo-history.md @@ -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` diff --git a/todo.md b/todo.md index 184481d..d18cdf4 100644 --- a/todo.md +++ b/todo.md @@ -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. (`
`, ``), 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.**