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