diff --git a/src/markdown/emit/plain-inline.ts b/src/markdown/emit/plain-inline.ts new file mode 100644 index 0000000..daabff4 --- /dev/null +++ b/src/markdown/emit/plain-inline.ts @@ -0,0 +1,236 @@ +import type { 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) + 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 (['extension', 'inlineExtension', 'mention', 'status', 'syncBlock'].includes(node.type)) return success(textLeaves(attrs['text'], marks, container)) + if (['media', 'mediaInline'].includes(node.type)) return success(textLeaves(attrs['alt'], marks, container)) + if (['blockCard', 'embedCard', 'inlineCard'].includes(node.type)) return success(cardLeaves(attrs['url'], 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(url: unknown, marks: readonly AdfMark[], container: LineContainer): AdfNode[] { + if (typeof url !== 'string') return [] + return textLeaves(url, [...marks.filter((mark) => mark.type !== 'link'), { attrs: { href: url }, type: 'link' }], container) +} + +function nonEmpty(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? 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): AdfNode[] { + if (typeof value !== 'string') return [] + 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 href = attrs['href'] + const title = attrs['title'] + const pipes = container === 'table-cell' + if (typeof href !== 'string' || spellDestination(href) === undefined || (pipes && href.includes('|'))) return undefined + if (typeof title !== 'string' || spellLinkTarget(href, title) === undefined || (pipes && title.includes('|'))) return { attrs: { href }, type: 'link' } + return { attrs: { href, title }, type: 'link' } +} + +// 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 +} + +// The marks the whitespace keeps, or undefined where it goes. +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 + 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..b1be40c --- /dev/null +++ b/src/markdown/emit/plain-reduction.test.ts @@ -0,0 +1,289 @@ +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: 'a', 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: 'a', 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: 'a', 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: 'a' } + assert.equal(plain(node('paragraph', localId, text('x')), node('heading', { level: 2, localId: 'a' }, 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: 'a', 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') + 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') +}) + +test('spells a card as a link to its url, dropping one carrying only data', () => { + 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\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: {} })), '') +}) + +test('keeps an external image and spells other media as their alt text', () => { + 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' }))), 'One\n') + assert.equal(plain(paragraph(text('a '), node('mediaInline', { alt: 'clip', type: 'file' }), node('mediaInline', { type: 'file' }))), 'a clip\n') + assert.equal(plain(caption), 'The moon.\n') +}) + +test('spells an extension as its text attribute and a placeholder as nothing', () => { + assert.equal(plain(node('extension', { extensionKey: 'toc', text: 'Contents' }), node('extension', { extensionKey: 'toc' })), 'Contents\n') + assert.equal(plain(node('syncBlock', { resourceId: 'r' })), '') + assert.equal(plain(paragraph(text('a '), node('inlineExtension', { text: 'macro' }), node('placeholder', { text: 'Type here' }))), 'a macro\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(node('rule', {}), said('x')))), '---\n\nx\n') + assert.equal(plain(bulletList(item({ content: [text('a\n \nb')], type: 'codeBlock' }))), '```\na\n \nb\n```\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 }, said('wide')), cell('tableCell', said('c'))), + row(cell('tableCell')), + ) + assert.equal(plain(spanned), '| A | B | C |\n| --- | --- | --- |\n| wide | c | |\n| | | |\n') + assert.equal(plain(node('table', {}, row(cell('tableHeader', paragraph(text('a|b', code), text(' '), text('x', link('https://e.com/|'))))))), '| a\\|b x |\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('spells a link no CommonMark escape writes as its text', () => { + assert.equal(plain(paragraph(text('a', link('a\\b')), text(' '), text('b', { attrs: { id: 'i' }, type: 'link' }))), 'a b\n') + assert.equal(plain(paragraph(text('t', link('https://e.com', 'two\nlines')), text(' '), text('u', link('https://e.com', 'Title')))), '[t](https://e.com) [u](https://e.com "Title")\n') + assert.equal(plain(paragraph(text(']: a', link('/u'), code))), '`]: a`\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(7, 'b'), bulletList(item(said('c')))), '2. a\n3. b\n\n- c\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..4a2fcf8 --- /dev/null +++ b/src/markdown/emit/plain-reduction.ts @@ -0,0 +1,268 @@ +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, type SpellingMemo } from './adf-to-markdown.ts' +import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' +import { inlineLeaves, isBlockNodeType, reduceInline } 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 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: paragraphOfNode, + mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index) => (child.type === 'media' ? imageOrAlt : 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 or a table still taking the directive form gives way to its 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 block = sequence[index] + if (block === undefined || !['bulletList', 'orderedList', 'table'].includes(block.type)) continue + if (commonMarkSpelling(block, reduction.path, reduction.depth, reduction.memo)?.ok === true) continue + sequence = mergedLists([...sequence.slice(0, index), ...heldBlocks(block), ...sequence.slice(index + 1)]) + index = Math.max(-1, index - 2) + } + return success(sequence) +} + +function heldBlocks(node: AdfNode): AdfNode[] { + const blocks: AdfNode[] = [] + for (const child of nodeContent(node)) { + const held = ['listItem', 'tableCell', 'tableHeader', 'tableRow'].includes(child.type) ? heldBlocks(child) : [child] + for (const block of held) blocks.push(block) + } + return blocks +} + +function mergedLists(blocks: readonly AdfNode[]): AdfNode[] { + const merged: AdfNode[] = [] + for (const block of blocks) { + const previous = merged[merged.length - 1] + if (previous !== undefined && previous.type === block.type && (block.type === 'bulletList' || block.type === 'orderedList')) { + merged[merged.length - 1] = { ...previous, content: [...nodeContent(previous), ...nodeContent(block)] } + } else { + merged.push(block) + } + } + return merged +} + +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([{ content: blocks.value, type: 'listItem' }]) : blocks +} + +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({ content: previous === undefined ? blocks.value : mergedLists([...nodeContent(previous), ...blocks.value]), type: 'listItem' }) + } + 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) + if (!blocks.ok) return blocks + const [first, ...rest] = blocks.value + if (first?.type === 'paragraph') return success([paragraph([text(`${marker} `), ...nodeContent(first)]), ...rest]) + return success([paragraph([text(marker)]), ...blocks.value]) +} + +function reduceTable(node: AdfNode, reduction: Reduction): Result { + const grid: AdfNode[][] = [] + for (const [rowIndex, row] of nodeContent(node).entries()) { + const rowReduction = childReduction(reduction, rowIndex) + const cells = concatenated((row.type === 'tableRow' ? nodeContent(row) : [row]).map((cell, cellIndex) => cellParagraph(cell, childReduction(rowReduction, cellIndex)))) + if (!cells.ok) return cells + grid.push(cells.value) + } + const width = grid.reduce((widest, cells) => Math.max(widest, cells.length), 0) + const rows = grid.map((cells, rowIndex) => { + const padded = [...cells, ...Array.from({ length: width - cells.length }, (): AdfNode => ({ type: 'paragraph' }))] + return { content: padded.map((cell): AdfNode => ({ content: [cell], type: rowIndex === 0 ? 'tableHeader' : 'tableCell' })), type: 'tableRow' } + }) + return success(width === 0 ? [] : [{ content: rows, type: 'table' }]) +} + +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 imageOrAlt(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' ? held.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ').trim() : '' + const image: AdfNode = { attrs: { layout: 'center' }, content: [{ attrs: alt === '' ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' }], type: 'mediaSingle' } + return commonMarkSpelling(image, reduction.path, reduction.depth, reduction.memo)?.ok === true ? success([image]) : paragraphOfNode(media, reduction) +}