Files
adf-codec/src/markdown/emit/plain-inline.ts
T
2026-09-29 12:05:04 +02:00

296 lines
15 KiB
TypeScript

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<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 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<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 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<number, number>()
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))
})
}