From 37abc1ddc1750238a071332b22a9ef9b248fa607 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 24 Sep 2026 00:59:36 +0200 Subject: [PATCH 01/20] 10a - the line emitter names the first fallback a plain line would take, and the mark run it drops --- src/markdown/emit/inline-line.ts | 32 +++++++++++++++++++++++------- src/markdown/emit/line-escaping.ts | 12 ++++++----- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 90be255..432fde4 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 } | { 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,17 @@ 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 assembled = assembleInlineLine(emission.value.segments, container) + if (assembled.openingLinkAsDirective) return success({ kind: 'opening-link' }) + if (assembled.unspellableRun !== undefined) return success({ kind: 'unspellable-run', run: assembled.unspellableRun }) + const claimed = claimedLine(assembled.line, container) + return success(claimed === undefined ? undefined : { kind: 'claimed-line', line: claimed }) +} + export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined { const emitted = emitLine(nodes, 'table-cell', path) if (!emitted.ok) return undefined @@ -108,14 +121,19 @@ function attemptLine(segments: readonly InlineSegment[], container: LineContaine 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) - } + const claimed = claimedLine(assembled.line, container) + if (claimed !== undefined) { + return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(assembled.line.split('\n')[claimed])}`, path) } return success({ line: assembled.line }) } +function claimedLine(line: string, container: LineContainer): number | undefined { + if (container !== 'paragraph') return undefined + const index = line.split('\n').findIndex((single, index) => claimsLine(single, index === 0 ? 'first' : 'later')) + return index === -1 ? undefined : index +} + // spec/flavour.md, Inline nodes. function carryStrippedWhitespace(segments: readonly InlineSegment[]): InlineSegment[] { const carried: InlineSegment[] = [] @@ -275,9 +293,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) { -- 2.52.0 From 59a37d19451c449033269bfc8798b99540a457ef Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 24 Sep 2026 00:59:36 +0200 Subject: [PATCH 02/20] 10a - the reduction spells a document in the flavour without directives --- src/markdown/emit/plain-inline.ts | 236 ++++++++++++++++++ src/markdown/emit/plain-reduction.test.ts | 289 ++++++++++++++++++++++ src/markdown/emit/plain-reduction.ts | 268 ++++++++++++++++++++ 3 files changed, 793 insertions(+) create mode 100644 src/markdown/emit/plain-inline.ts create mode 100644 src/markdown/emit/plain-reduction.test.ts create mode 100644 src/markdown/emit/plain-reduction.ts 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) +} -- 2.52.0 From c6acef081fddf0099156d337dff1874121e72489 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:10:47 +0200 Subject: [PATCH 03/20] 10a - the reduction keeps what a reader sees or follows, and names what the document only references --- src/markdown/emit/plain-inline.ts | 73 +++++++++++++++---- src/markdown/emit/plain-reduction.test.ts | 46 +++++++----- src/markdown/emit/plain-reduction.ts | 88 ++++++++++++++++++----- 3 files changed, 154 insertions(+), 53 deletions(-) diff --git a/src/markdown/emit/plain-inline.ts b/src/markdown/emit/plain-inline.ts index daabff4..f4bd55e 100644 --- a/src/markdown/emit/plain-inline.ts +++ b/src/markdown/emit/plain-inline.ts @@ -1,4 +1,4 @@ -import type { AdfMark, AdfNode } from '../../adf/document.ts' +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' @@ -31,7 +31,7 @@ export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer 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) + 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) @@ -47,18 +47,47 @@ function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorP 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)) + 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, nonEmpty(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(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 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 nonEmpty(value: unknown): string | undefined { @@ -77,8 +106,9 @@ 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 [] +// note: what the content is named in the note left where it has none. +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[] = [] @@ -108,14 +138,24 @@ function plainMarks(marks: readonly AdfMark[], container: LineContainer, text: s 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' } + 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[] = [] @@ -222,6 +262,9 @@ function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback 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) } diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts index b1be40c..ec8d5f2 100644 --- a/src/markdown/emit/plain-reduction.test.ts +++ b/src/markdown/emit/plain-reduction.test.ts @@ -172,35 +172,40 @@ test('spells an inline node as its text', () => { 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, dropping one carrying only data', () => { +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\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: {} })), '') + assert.equal(plain(node('blockCard', { data: {} })), '_(link card not included)_\n') }) -test('keeps an external image and spells other media as their alt text', () => { +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: '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(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 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 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(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', () => { @@ -210,8 +215,8 @@ test('spells a node no row names, or one standing where no spelling holds it, as 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', {}), 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') }) @@ -227,11 +232,14 @@ test('keeps a table as a pipe table headed by its first row, one line per cell', '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(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| | | |\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(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'))] @@ -246,10 +254,10 @@ test('keeps code, em, link, strike and strong and drops every other mark, keepin 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('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', () => { diff --git a/src/markdown/emit/plain-reduction.ts b/src/markdown/emit/plain-reduction.ts index 4a2fcf8..48eb9e0 100644 --- a/src/markdown/emit/plain-reduction.ts +++ b/src/markdown/emit/plain-reduction.ts @@ -3,7 +3,7 @@ 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 { inlineLeaves, isBlockNodeType, reduceInline, writableHref } from './plain-inline.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' @@ -13,6 +13,8 @@ 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> = { @@ -35,8 +37,8 @@ const blockReducers: Readonly> = { 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)))), + 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), @@ -207,7 +209,17 @@ function reduceItems(node: AdfNode, reduction: Reduction, itemBlocks: (child: Ad } function listItem(blocks: Result): Result { - return blocks.ok ? success([{ content: blocks.value, type: 'listItem' }]) : blocks + return blocks.ok ? success([itemOf(blocks.value)]) : blocks +} + +// A list item holds no line of spaces alone, and a code line of them is what a reader does not see. +function itemOf(blocks: readonly AdfNode[]): AdfNode { + const content = blocks.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 { @@ -216,7 +228,7 @@ function reduceTaskList(node: AdfNode, reduction: Reduction): Result 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' }) + items.push(itemOf(previous === undefined ? blocks.value : mergedLists([...nodeContent(previous), ...blocks.value]))) } return success(listOf(items, 'bulletList')) } @@ -236,33 +248,71 @@ function taskBlocks(child: AdfNode, at: Reduction): Result { } function reduceTable(node: AdfNode, reduction: Reduction): Result { - const grid: AdfNode[][] = [] + const rows: PlacedCell[][] = [] 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 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 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' }]) + 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 cellParagraph(cell: AdfNode, reduction: Reduction): Result { +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 + return content.ok ? success(content.value.length === 0 ? { type: 'paragraph' } : paragraph(content.value)) : content } -function imageOrAlt(media: AdfNode, reduction: Reduction): Result { +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' ? 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) + 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]) } -- 2.52.0 From 1104c57c0eae6428917c28c0b592cab736415533 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:13:26 +0200 Subject: [PATCH 04/20] 10a - a rule opening a list item gives way, and the list stays a list --- src/markdown/emit/plain-reduction.test.ts | 3 ++- src/markdown/emit/plain-reduction.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts index ec8d5f2..ad116b5 100644 --- a/src/markdown/emit/plain-reduction.test.ts +++ b/src/markdown/emit/plain-reduction.test.ts @@ -216,7 +216,8 @@ test('spells a node no row names, or one standing where no spelling holds it, as 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', {}), said('x')))), '---\n\nx\n') + assert.equal(plain(bulletList(item(node('rule', {}), said('Install')), item(said('Configure')))), '- Install\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') }) diff --git a/src/markdown/emit/plain-reduction.ts b/src/markdown/emit/plain-reduction.ts index 48eb9e0..2d1de39 100644 --- a/src/markdown/emit/plain-reduction.ts +++ b/src/markdown/emit/plain-reduction.ts @@ -212,9 +212,9 @@ function listItem(blocks: Result): Result { return blocks.ok ? success([itemOf(blocks.value)]) : blocks } -// A list item holds no line of spaces alone, and a code line of them is what a reader does not see. +// 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 content = blocks.map((block) => (block.type === 'codeBlock' ? { ...block, content: nodeContent(block).map(blankedLines) } : block)) + const content = (blocks[0]?.type === 'rule' ? blocks.slice(1) : blocks).map((block) => (block.type === 'codeBlock' ? { ...block, content: nodeContent(block).map(blankedLines) } : block)) return { content, type: 'listItem' } } -- 2.52.0 From 69f116db1b15b808ad9624d5df73255aad6be080 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:14:05 +0200 Subject: [PATCH 05/20] 10a - item 10's rows carry the panels' verdicts, and 10a is done --- todo-history.md | 3 +++ todo.md | 16 +++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) 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..d61dce5 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. @@ -220,19 +219,23 @@ 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). - `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,8 +248,7 @@ 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 -- 2.52.0 From c46b8243ba5fc9d329e4be4a71763a8a660fde5e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:27:40 +0200 Subject: [PATCH 06/20] 10a - tests: clean note names, drop every leading rule, keep an overflowing numbered list, random localIds --- src/markdown/emit/plain-reduction.test.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts index ad116b5..14e1d3b 100644 --- a/src/markdown/emit/plain-reduction.test.ts +++ b/src/markdown/emit/plain-reduction.test.ts @@ -99,7 +99,7 @@ test('refuses nesting past 500 levels wherever the reduction walks', () => { 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.'))) + 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') @@ -114,7 +114,7 @@ test('spells a panel as an alert in the GitHub word for its colour', () => { 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)), + 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') @@ -122,7 +122,7 @@ test('spells an expand and a nested expand as a folded callout titled by the mar }) 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 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') @@ -154,16 +154,19 @@ test('unwraps the containers plain markdown has no spelling for to their body bl }) 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') + 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: 'a', order: 3 }, item(said('c')))), '3. c\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') + assert.equal(plain(node('orderedList', { order: 999999999 }, item(said('a'))), node('orderedList', { order: 5 }, item(said('b')))), '- 999999999. a\n- 1000000000. b\n') assert.equal(plain(node('heading', { level: 7 }, text('h'))), 'h\n') }) @@ -205,6 +208,7 @@ test('keeps an external image wherever it stands and spells a stored file as its 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(paragraph(text('a '), node('inlineExtension', { text: 'macro' }), text(' '), node('inlineExtension', {}), node('placeholder', { text: 'Type here' }))), 'a macro _(extension not included)_\n') }) @@ -216,7 +220,8 @@ test('spells a node no row names, or one standing where no spelling holds it, as 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', {}), said('Install')), item(said('Configure')))), '- Install\n- Configure\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') }) -- 2.52.0 From d35637c8fa86a76f3c6f16a4a69ea302d388f37e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:28:53 +0200 Subject: [PATCH 07/20] 10a - tests: a marker-shaped number opening an item stays escaped --- src/markdown/emit/plain-reduction.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts index 14e1d3b..9814277 100644 --- a/src/markdown/emit/plain-reduction.test.ts +++ b/src/markdown/emit/plain-reduction.test.ts @@ -166,7 +166,7 @@ test('keeps the CommonMark blocks in their spelling and drops their attributes a 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') - assert.equal(plain(node('orderedList', { order: 999999999 }, item(said('a'))), node('orderedList', { order: 5 }, item(said('b')))), '- 999999999. a\n- 1000000000. b\n') + assert.equal(plain(node('orderedList', { order: 999999999 }, item(said('a'))), node('orderedList', { order: 5 }, item(said('b')))), '- 999999999\\. a\n- 1000000000. b\n') assert.equal(plain(node('heading', { level: 7 }, text('h'))), 'h\n') }) -- 2.52.0 From 8418150cd3a67d02f3b89f7b503aff44c6f34cf2 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:28:53 +0200 Subject: [PATCH 08/20] 10a - notes clean their names, leading rules give way, an overflowing numbered list keeps its numbers, one fallback order --- src/markdown/emit/adf-to-markdown.ts | 2 +- src/markdown/emit/inline-line.ts | 34 ++++++++++----------- src/markdown/emit/plain-inline.ts | 2 +- src/markdown/emit/plain-reduction.ts | 44 ++++++++++++++++------------ 4 files changed, 44 insertions(+), 38 deletions(-) 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 432fde4..796c8e1 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -35,7 +35,7 @@ type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } | type LineFallbacks = { carried: Set; openingLinkAsDirective: boolean } -export type PlainLineFallback = { kind: 'claimed-line'; line: number } | { kind: 'opening-link' } | { kind: 'unspellable-run'; run: MarkRun } +export type PlainLineFallback = { kind: 'claimed-line'; line: number; text: string } | { kind: 'opening-link' } | { kind: 'unspellable-run'; run: MarkRun } export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { const emitted = emitLine(nodes, container, path) @@ -53,11 +53,8 @@ export function plainLineFallback(nodes: readonly AdfNode[], container: LineCont const emission = lineSegments(nodes, container, path, { carried: new Set(), openingLinkAsDirective: false }) 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 assembled = assembleInlineLine(emission.value.segments, container) - if (assembled.openingLinkAsDirective) return success({ kind: 'opening-link' }) - if (assembled.unspellableRun !== undefined) return success({ kind: 'unspellable-run', run: assembled.unspellableRun }) - const claimed = claimedLine(assembled.line, container) - return success(claimed === undefined ? undefined : { kind: 'claimed-line', line: claimed }) + const verdict = lineVerdict(emission.value.segments, container) + return success(verdict.kind === 'line' ? undefined : verdict) } export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined { @@ -118,20 +115,21 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: } function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result { - const assembled = assembleInlineLine(segments, container) - if (assembled.openingLinkAsDirective) return success({ fallback: 'opening-link' }) - if (assembled.unspellableRun !== undefined) return success({ fallback: assembled.unspellableRun }) - const claimed = claimedLine(assembled.line, container) - if (claimed !== undefined) { - return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(assembled.line.split('\n')[claimed])}`, path) - } - return success({ line: assembled.line }) + 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 }) } -function claimedLine(line: string, container: LineContainer): number | undefined { - if (container !== 'paragraph') return undefined - const index = line.split('\n').findIndex((single, index) => claimsLine(single, index === 0 ? 'first' : 'later')) - return index === -1 ? undefined : index +// 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 { 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. diff --git a/src/markdown/emit/plain-inline.ts b/src/markdown/emit/plain-inline.ts index f4bd55e..401e561 100644 --- a/src/markdown/emit/plain-inline.ts +++ b/src/markdown/emit/plain-inline.ts @@ -87,7 +87,7 @@ function mediaLeaves(attrs: Readonly, marks: readonly AdfMark[], } function noteLeaves(name: string): AdfNode[] { - return [textLeaf(`(${name} not included)`, [{ type: 'em' }])] + return [textLeaf(`(${name.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ')} not included)`, [{ type: 'em' }])] } function nonEmpty(value: unknown): string | undefined { diff --git a/src/markdown/emit/plain-reduction.ts b/src/markdown/emit/plain-reduction.ts index 2d1de39..1729ba3 100644 --- a/src/markdown/emit/plain-reduction.ts +++ b/src/markdown/emit/plain-reduction.ts @@ -1,7 +1,7 @@ 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 { commonMarkSpelling, largestListMarker, type SpellingMemo } from './adf-to-markdown.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { inlineLeaves, isBlockNodeType, reduceInline, writableHref } from './plain-inline.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' @@ -107,26 +107,29 @@ function concatenated(results: readonly Result[]): Result return success(blocks) } -// A list or a table still taking the directive form gives way to its blocks. +// A list still taking the directive form gives way to its items' blocks. function plainSequence(blocks: readonly AdfNode[], reduction: Reduction): Result { 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 + const listed = sequence[index] + if (listed === undefined || (listed.type !== 'bulletList' && listed.type !== 'orderedList')) continue + const block = numberedPastMarkers(listed) + sequence[index] = block if (commonMarkSpelling(block, reduction.path, reduction.depth, reduction.memo)?.ok === true) continue - sequence = mergedLists([...sequence.slice(0, index), ...heldBlocks(block), ...sequence.slice(index + 1)]) - index = Math.max(-1, index - 2) + const held = nodeContent(block).flatMap(nodeContent) + const from = Math.max(0, index - 1) + sequence = [...sequence.slice(0, from), ...mergedLists([...sequence.slice(from, index), ...held, ...sequence.slice(index + 1, index + 2)]), ...sequence.slice(index + 2)] + index = from - 1 } return success(sequence) } -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 +// 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'] + const items = nodeContent(list) + if (list.type !== 'orderedList' || typeof order !== 'number' || order + items.length - 1 <= largestListMarker) return list + return { content: items.map((item, offset) => itemOf(marked(nodeContent(item), `${order + offset}.`))), type: 'bulletList' } } function mergedLists(blocks: readonly AdfNode[]): AdfNode[] { @@ -214,7 +217,8 @@ function listItem(blocks: Result): Result { // 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 content = (blocks[0]?.type === 'rule' ? blocks.slice(1) : blocks).map((block) => (block.type === 'codeBlock' ? { ...block, content: nodeContent(block).map(blankedLines) } : block)) + const rules = blocks.findIndex((block) => block.type !== 'rule') + const content = blocks.slice(rules === -1 ? blocks.length : rules).map((block) => (block.type === 'codeBlock' ? { ...block, content: nodeContent(block).map(blankedLines) } : block)) return { content, type: 'listItem' } } @@ -241,10 +245,14 @@ function taskBlocks(child: AdfNode, at: Reduction): Result { } 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]) + 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 { -- 2.52.0 From 1c0a6526b1c1593cd19b78847271d0f83adee02f Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:29:12 +0200 Subject: [PATCH 09/20] 10a - item 10's list row keeps a long list's numbers, and 33 names the reduction's retry --- todo.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/todo.md b/todo.md index d61dce5..bc5ef27 100644 --- a/todo.md +++ b/todo.md @@ -60,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`).** @@ -235,7 +236,8 @@ chunk clearing a §11 seam. 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 — and a rule opening an - item drops (a panel, 3 of 3, 2026-09-25). + item drops (a panel, 3 of 3, 2026-09-25); an ordered list running past `999999999` is a bullet + list keeping its numbers as text (a panel, 3 of 3, 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 -- 2.52.0 From cf9a737e71f7dd40d1d16c0de362b84f1ce04305 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:31:59 +0200 Subject: [PATCH 10/20] 10a - 10c's property holds the plain output to no directive --- todo.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/todo.md b/todo.md index bc5ef27..604ba19 100644 --- a/todo.md +++ b/todo.md @@ -254,8 +254,8 @@ chunk clearing a §11 seam. - [ ] **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.** -- 2.52.0 From 5e55ce2a8e9744a2e3372b65b38f84e6a1c61044 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:32:13 +0200 Subject: [PATCH 11/20] 10a - tests: an overflowing numbered list merges with its neighbours, and a blank key names an extension --- src/markdown/emit/plain-reduction.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts index 9814277..9f58b77 100644 --- a/src/markdown/emit/plain-reduction.test.ts +++ b/src/markdown/emit/plain-reduction.test.ts @@ -166,6 +166,12 @@ test('keeps the CommonMark blocks in their spelling and drops their attributes a 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 - -\n- z\n') assert.equal(plain(node('orderedList', { order: 999999999 }, item(said('a'))), node('orderedList', { order: 5 }, item(said('b')))), '- 999999999\\. a\n- 1000000000. b\n') assert.equal(plain(node('heading', { level: 7 }, text('h'))), 'h\n') }) @@ -209,6 +215,7 @@ test('spells an extension as its text attribute, else a note naming it, and a pl 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') assert.equal(plain(paragraph(text('a '), node('inlineExtension', { text: 'macro' }), text(' '), node('inlineExtension', {}), node('placeholder', { text: 'Type here' }))), 'a macro _(extension not included)_\n') }) -- 2.52.0 From 7b814e02dc4c4d13302aedc11e844d56e58af7e3 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:32:45 +0200 Subject: [PATCH 12/20] 10a - tests: the give-way case spells its nested list tight --- src/markdown/emit/plain-reduction.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts index 9f58b77..7ee1a07 100644 --- a/src/markdown/emit/plain-reduction.test.ts +++ b/src/markdown/emit/plain-reduction.test.ts @@ -171,7 +171,7 @@ test('keeps the CommonMark blocks in their spelling and drops their attributes a 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 - -\n- z\n') + 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- 1000000000. b\n') assert.equal(plain(node('heading', { level: 7 }, text('h'))), 'h\n') }) -- 2.52.0 From dc11daa54041b429ae75157e12d60a32c91a9070 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:33:26 +0200 Subject: [PATCH 13/20] 10a - a list a numbered list becomes merges with its neighbours, and a note's name is cleaned before it is checked --- src/markdown/emit/plain-inline.ts | 12 ++++++++++-- src/markdown/emit/plain-reduction.ts | 20 ++++++++++++-------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/markdown/emit/plain-inline.ts b/src/markdown/emit/plain-inline.ts index 401e561..4cd11e7 100644 --- a/src/markdown/emit/plain-inline.ts +++ b/src/markdown/emit/plain-inline.ts @@ -49,7 +49,7 @@ function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorP 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, nonEmpty(attrs['extensionKey']) ?? 'extension')) + 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)) @@ -87,7 +87,15 @@ function mediaLeaves(attrs: Readonly, marks: readonly AdfMark[], } function noteLeaves(name: string): AdfNode[] { - return [textLeaf(`(${name.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ')} not included)`, [{ type: 'em' }])] + return [textLeaf(`(${name} not included)`, [{ type: 'em' }])] +} + +function noteName(value: unknown): string | undefined { + return typeof value === 'string' ? nonEmpty(oneLine(value).trim()) : undefined +} + +export function oneLine(text: string): string { + return text.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ') } function nonEmpty(value: unknown): string | undefined { diff --git a/src/markdown/emit/plain-reduction.ts b/src/markdown/emit/plain-reduction.ts index 1729ba3..5be4c8e 100644 --- a/src/markdown/emit/plain-reduction.ts +++ b/src/markdown/emit/plain-reduction.ts @@ -3,7 +3,7 @@ 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, reduceInline, writableHref } from './plain-inline.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' @@ -114,16 +114,20 @@ function plainSequence(blocks: readonly AdfNode[], reduction: Reduction): Result const listed = sequence[index] if (listed === undefined || (listed.type !== 'bulletList' && listed.type !== 'orderedList')) continue const block = numberedPastMarkers(listed) - sequence[index] = block - if (commonMarkSpelling(block, reduction.path, reduction.depth, reduction.memo)?.ok === true) continue - const held = nodeContent(block).flatMap(nodeContent) - const from = Math.max(0, index - 1) - sequence = [...sequence.slice(0, from), ...mergedLists([...sequence.slice(from, index), ...held, ...sequence.slice(index + 1, index + 2)]), ...sequence.slice(index + 2)] - index = from - 1 + 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'] @@ -319,7 +323,7 @@ function reduceMedia(media: AdfNode, reduction: Reduction): Result { 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 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]) -- 2.52.0 From 3a18627389d4d2f7fd03fa31fd77acad81b85532 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:36:59 +0200 Subject: [PATCH 14/20] 10a - tests: a value blank once cleaned leaves the note or fallback --- src/markdown/emit/plain-reduction.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts index 7ee1a07..34f70a2 100644 --- a/src/markdown/emit/plain-reduction.test.ts +++ b/src/markdown/emit/plain-reduction.test.ts @@ -216,6 +216,8 @@ test('spells an extension as its text attribute, else a note naming it, and a pl 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') }) -- 2.52.0 From 8f33f67f7260e006de9f75f2585ce18a3b38d816 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:37:21 +0200 Subject: [PATCH 15/20] 10a - a value blank once cleaned counts as empty --- src/markdown/emit/plain-inline.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/markdown/emit/plain-inline.ts b/src/markdown/emit/plain-inline.ts index 4cd11e7..54d5869 100644 --- a/src/markdown/emit/plain-inline.ts +++ b/src/markdown/emit/plain-inline.ts @@ -91,15 +91,16 @@ function noteLeaves(name: string): AdfNode[] { } function noteName(value: unknown): string | undefined { - return typeof value === 'string' ? nonEmpty(oneLine(value).trim()) : 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, ' ') } +// Blank once a text leaf's cleaning has run is empty. function nonEmpty(value: unknown): string | undefined { - return typeof value === 'string' && value !== '' ? value : undefined + return typeof value === 'string' && oneLine(value).trim() !== '' ? value : undefined } function isoDate(timestamp: unknown): string | undefined { -- 2.52.0 From 62ece3f9acddb680940126a8cc1b7cf5a36a6d99 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:38:46 +0200 Subject: [PATCH 16/20] 10a - tests: numbered lists whose numbering breaks keep their numbers as text --- src/markdown/emit/plain-reduction.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts index 34f70a2..b89539f 100644 --- a/src/markdown/emit/plain-reduction.test.ts +++ b/src/markdown/emit/plain-reduction.test.ts @@ -172,7 +172,7 @@ test('keeps the CommonMark blocks in their spelling and drops their attributes a 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- 1000000000. b\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') }) @@ -301,7 +301,9 @@ 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') + 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') }) -- 2.52.0 From 8572d76ee928a7c591bc62b4a063ce4e8ca21653 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:39:16 +0200 Subject: [PATCH 17/20] 10a - tests: a marker-shaped number opening an item stays escaped --- src/markdown/emit/plain-reduction.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/markdown/emit/plain-reduction.test.ts b/src/markdown/emit/plain-reduction.test.ts index b89539f..6aafd56 100644 --- a/src/markdown/emit/plain-reduction.test.ts +++ b/src/markdown/emit/plain-reduction.test.ts @@ -172,7 +172,7 @@ test('keeps the CommonMark blocks in their spelling and drops their attributes a 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('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') }) @@ -302,8 +302,8 @@ test('drops an empty paragraph and merges adjacent lists of one type', () => { 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') + 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') }) -- 2.52.0 From dbd80b98c31033dc2ff52f2d0e2d8a447aa5f1d8 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:39:54 +0200 Subject: [PATCH 18/20] 10a - numbered lists whose numbering breaks merge as one bullet list keeping their numbers --- src/markdown/emit/plain-reduction.ts | 31 +++++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/markdown/emit/plain-reduction.ts b/src/markdown/emit/plain-reduction.ts index 5be4c8e..a66be46 100644 --- a/src/markdown/emit/plain-reduction.ts +++ b/src/markdown/emit/plain-reduction.ts @@ -131,24 +131,39 @@ function spliced(sequence: readonly AdfNode[], index: number, replacement: reado // 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'] - const items = nodeContent(list) - if (list.type !== 'orderedList' || typeof order !== 'number' || order + items.length - 1 <= largestListMarker) return list - return { content: items.map((item, offset) => itemOf(marked(nodeContent(item), `${order + offset}.`))), type: 'bulletList' } + 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) { - 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) + 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' } } -- 2.52.0 From 0094b361ab2ad9c8b5c1391578032c264f620353 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:40:13 +0200 Subject: [PATCH 19/20] 10a - item 10's list row keeps a broken numbering's numbers --- todo.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/todo.md b/todo.md index 604ba19..d18cdf4 100644 --- a/todo.md +++ b/todo.md @@ -236,8 +236,9 @@ chunk clearing a §11 seam. 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 — and a rule opening an - item drops (a panel, 3 of 3, 2026-09-25); an ordered list running past `999999999` is a bullet - list keeping its numbers as text (a panel, 3 of 3, 2026-09-25). + 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 -- 2.52.0 From 687ba9bf902f1c59c7e6b157155eb80a594b7d3a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 19:40:27 +0200 Subject: [PATCH 20/20] 10a - three comments restating their function go --- src/markdown/emit/plain-inline.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/markdown/emit/plain-inline.ts b/src/markdown/emit/plain-inline.ts index 54d5869..f4f3b09 100644 --- a/src/markdown/emit/plain-inline.ts +++ b/src/markdown/emit/plain-inline.ts @@ -98,7 +98,6 @@ export function oneLine(text: string): string { return text.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ') } -// Blank once a text leaf's cleaning has run is empty. function nonEmpty(value: unknown): string | undefined { return typeof value === 'string' && oneLine(value).trim() !== '' ? value : undefined } @@ -115,7 +114,6 @@ function lineBreak(container: LineContainer): AdfNode { return container === 'paragraph' ? { type: 'hardBreak' } : textLeaf(' ', []) } -// note: what the content is named in the note left where it has none. 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, '') @@ -235,7 +233,6 @@ function leafEdges(leaf: AdfNode, previous: AdfNode | undefined, next: AdfNode | 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' -- 2.52.0