import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts' import type { LineContainer } from '../line-container.ts' import type { MarkRun } from './line-escaping.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 highlightMark: AdfMark = { type: highlight } const edgeStrippingMarks: readonly string[] = [highlight, 'em', 'strike', 'strong'] const keptMarks: readonly string[] = [...edgeStrippingMarks, 'code', 'link'] export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result { const leaves = inlineLeaves(nodes, container, path, depth) if (!leaves.ok) return leaves return spellableLine(trimmedEdges(highlighted(trimmedEdges(leaves.value))), container, path) } export function isBlockNodeType(type: string): boolean { return blockNodeModel(type) !== undefined || type === 'blockCard' || type === 'embedCard' } export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result { if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path) const leaves: AdfNode[] = [] let joinsNext = false for (const [index, node] of nodes.entries()) { const held = nodeLeaves(node, container, [...path, 'content', index], depth) if (!held.ok) return held if (held.value.length === 0) continue const block = isBlockNodeType(node.type) && node.type !== 'media' if (leaves.length > 0 && (joinsNext || block)) leaves.push(textLeaf(' ', [])) joinsNext = block for (const leaf of held.value) leaves.push(leaf) } return success(leaves) } function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorPath, depth: number): Result { const marks = nodeMarks(node) const attrs = nodeAttrs(node) if (node.type === 'text') return success(textLeaves(node.text, marks, container)) if (node.type === 'hardBreak') return success([lineBreak(container)]) if (node.type === 'date') return success(textLeaves(isoDate(attrs['timestamp']), marks, container)) if (node.type === 'emoji') return success(textLeaves(nonEmpty(attrs['text']) ?? attrs['shortName'], marks, container)) if (node.type === 'placeholder') return success([]) if (node.type === 'mention') return success(textLeaves(nonEmpty(attrs['text']) ?? idMention(attrs['id']), marks, container)) if (node.type === 'status') return success(textLeaves(attrs['text'], marks, container)) if (['extension', 'inlineExtension'].includes(node.type)) return success(textLeaves(nonEmpty(attrs['text']), marks, container, noteName(attrs['extensionKey']) ?? 'extension')) if (node.type === 'syncBlock') return success(noteLeaves('synced block')) if (['media', 'mediaInline'].includes(node.type)) return success(mediaLeaves(attrs, marks, container)) if (['blockCard', 'embedCard', 'inlineCard'].includes(node.type)) return success(cardLeaves(attrs, marks, container)) const own = textLeaves(node.text ?? (['expand', 'nestedExpand'].includes(node.type) ? attrs['title'] : undefined), marks, container) const held = inlineLeaves(nodeContent(node), container, path, depth + 1) if (!held.ok) return held return success(own.length > 0 && held.value.length > 0 ? [...own, textLeaf(' ', []), ...held.value] : [...own, ...held.value]) } function cardLeaves(attrs: Readonly, marks: readonly AdfMark[], container: LineContainer): AdfNode[] { const data = attrs['data'] const held = typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {} const url = nonEmpty(attrs['url']) const heldUrl = nonEmpty(held['url']) const name = nonEmpty(held['name']) const href = url ?? heldUrl if (href === undefined) return textLeaves(name, marks, container, 'link card') return linkedLeaves(url ?? name ?? href, href, marks, container) } function linkedLeaves(text: string, href: string, marks: readonly AdfMark[], container: LineContainer): AdfNode[] { return textLeaves(text, [...marks.filter((mark) => mark.type !== 'link'), { attrs: { href }, type: 'link' }], container) } function idMention(id: unknown): string | undefined { return typeof id === 'string' && id !== '' ? `@${id}` : undefined } // An image standing inline is a link to it: CommonMark's inline image reads back as no node. function mediaLeaves(attrs: Readonly, marks: readonly AdfMark[], container: LineContainer): AdfNode[] { const alt = nonEmpty(attrs['alt']) const url = nonEmpty(attrs['url']) if (attrs['type'] !== 'external' || url === undefined) return textLeaves(alt, marks, container, 'image') return linkedLeaves(alt ?? url, url, marks, container) } function noteLeaves(name: string): AdfNode[] { return [textLeaf(`(${name} not included)`, [{ type: 'em' }])] } function noteName(value: unknown): string | undefined { return nonEmpty(value) === undefined ? undefined : oneLine(String(value)).trim() } export function oneLine(text: string): string { return text.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ') } function nonEmpty(value: unknown): string | undefined { return typeof value === 'string' && oneLine(value).trim() !== '' ? value : undefined } function isoDate(timestamp: unknown): string | undefined { const milliseconds = typeof timestamp === 'string' && /^-?\d+$/.test(timestamp) ? Number(timestamp) : Number.NaN const date = new Date(milliseconds) if (Number.isNaN(date.getTime())) return undefined const iso = date.toISOString() return iso.slice(0, iso.indexOf('T')) } function lineBreak(container: LineContainer): AdfNode { return container === 'paragraph' ? { type: 'hardBreak' } : textLeaf(' ', []) } function textLeaves(value: unknown, marks: readonly AdfMark[], container: LineContainer, note?: string): AdfNode[] { if (typeof value !== 'string') return note === undefined ? [] : noteLeaves(note) const text = value.replace(/[\r\u0000]/g, '') const kept = plainMarks(marks, container, text) const leaves: AdfNode[] = [] for (const [index, line] of text.split('\n').entries()) { if (index > 0) leaves.push(lineBreak(container)) if (line !== '') leaves.push(textLeaf(line, kept)) } return leaves } function textLeaf(text: string, marks: readonly AdfMark[]): AdfNode { return marks.length === 0 ? { text, type: 'text' } : { marks: [...marks], text, type: 'text' } } // A highlight goes first, where `highlighted` looks for it, and code last, 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) // The reader highlights no code, as Atlassian's schema allows none. const code = kept.some((mark) => mark.type === 'code') return kept.filter((mark) => !code || mark.type !== highlight).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 marks a whole highlight run shares go outside the highlight, so its delimiters 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 : shared.filter((mark) => held.some((other) => sameMark(other, mark))) run.push(leaf) continue } for (const held of run) spelled.push(withMarks(held, [...shared, highlightMark, ...nodeMarks(held).slice(1).filter((mark) => !shared.some((other) => sameMark(other, mark)))])) 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 = 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 if (lead === text) return leadDepth === undefined || trailDepth === undefined ? [] : [textLeaf(text, marks.slice(0, Math.min(leadDepth, trailDepth)))] const edges: AdfNode[] = [] if (lead !== '' && leadDepth !== undefined) edges.push(textLeaf(lead, marks.slice(0, leadDepth))) const core = text.slice(lead.length, text.length - trail.length) if (core !== '') edges.push(textLeaf(core, marks)) if (trail !== '' && trailDepth !== undefined) edges.push(textLeaf(trail, marks.slice(0, trailDepth))) return edges } function edgeDepth(marks: readonly AdfMark[], neighbour: AdfNode | undefined, whitespace: string): number | undefined { if (whitespace === '') return marks.length const lineEdge = neighbour === undefined || neighbour.type === 'hardBreak' const neighbourMarks = lineEdge ? [] : nodeMarks(neighbour) let shared = 0 while (shared < marks.length && sameMarkAt(marks, neighbourMarks, shared)) shared += 1 const stripping = marks.findIndex((mark, index) => index >= shared && edgeStrippingMarks.includes(mark.type)) const kept = stripping === -1 ? marks.length : stripping return kept === 0 && lineEdge ? undefined : kept } function sameMarkAt(marks: readonly AdfMark[], others: readonly AdfMark[], index: number): boolean { const mark = marks[index] const other = others[index] return mark !== undefined && other !== undefined && sameMark(mark, other) } function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result { for (let current = leaves; ; ) { const fallback = plainLineFallback(current, container, path) if (!fallback.ok) return fallback if (fallback.value === undefined) return success(current) const fixed = withoutFallback(current, fallback.value) if (fixed === undefined) return failure('unsupported-node-shape', 'a plain line keeps a spelling that dropping a mark does not change', path) current = trimmedEdges(fixed) } } function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback): AdfNode[] | undefined { if (fallback.kind === 'unspellable-run') return withoutMarks(leaves, fallback.runs) 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 withoutMarks(leaves, [{ depth: 0, first, last }]) } 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 } // The runs cover disjoint leaves, so one pass drops them all. function withoutMarks(leaves: readonly AdfNode[], runs: readonly MarkRun[]): AdfNode[] { const depths = new Map() for (const run of runs) for (let index = run.first; index <= run.last; index += 1) depths.set(index, run.depth) return leaves.map((leaf, index) => { const depth = depths.get(index) return depth === undefined ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => held !== depth)) }) }