10a - the reduction spells a document in the flavour without directives
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import { commonMarkSpelling, type SpellingMemo } from './adf-to-markdown.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { inlineLeaves, isBlockNodeType, reduceInline } from './plain-inline.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
|
||||
// depth: the level the node reduced stands at, counted as the emitter counts it.
|
||||
type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
|
||||
|
||||
type BlockReducer = (node: AdfNode, reduction: Reduction) => Result<AdfNode[]>
|
||||
|
||||
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: paragraphOfNode,
|
||||
mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index) => (child.type === 'media' ? imageOrAlt : reduceStanding)(child, childReduction(reduction, index)))),
|
||||
nestedExpand: reduceExpand,
|
||||
orderedList: reduceList,
|
||||
panel: (node, reduction) => quoted(success([paragraph([text(`[!${alertWord(nodeAttrs(node)['panelType'])}]`)])]), node, reduction),
|
||||
paragraph: (node, reduction) => paragraphOf(nodeContent(node), reduction),
|
||||
rule: () => success([{ type: 'rule' }]),
|
||||
syncBlock: paragraphOfNode,
|
||||
table: reduceTable,
|
||||
taskList: reduceTaskList,
|
||||
}
|
||||
|
||||
export function reduceToPlain(document: AdfDocument): Result<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 or a table still taking the directive form gives way to its 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 block = sequence[index]
|
||||
if (block === undefined || !['bulletList', 'orderedList', 'table'].includes(block.type)) continue
|
||||
if (commonMarkSpelling(block, reduction.path, reduction.depth, reduction.memo)?.ok === true) continue
|
||||
sequence = mergedLists([...sequence.slice(0, index), ...heldBlocks(block), ...sequence.slice(index + 1)])
|
||||
index = Math.max(-1, index - 2)
|
||||
}
|
||||
return success(sequence)
|
||||
}
|
||||
|
||||
function heldBlocks(node: AdfNode): AdfNode[] {
|
||||
const blocks: AdfNode[] = []
|
||||
for (const child of nodeContent(node)) {
|
||||
const held = ['listItem', 'tableCell', 'tableHeader', 'tableRow'].includes(child.type) ? heldBlocks(child) : [child]
|
||||
for (const block of held) blocks.push(block)
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
function mergedLists(blocks: readonly AdfNode[]): AdfNode[] {
|
||||
const merged: AdfNode[] = []
|
||||
for (const block of blocks) {
|
||||
const previous = merged[merged.length - 1]
|
||||
if (previous !== undefined && previous.type === block.type && (block.type === 'bulletList' || block.type === 'orderedList')) {
|
||||
merged[merged.length - 1] = { ...previous, content: [...nodeContent(previous), ...nodeContent(block)] }
|
||||
} else {
|
||||
merged.push(block)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function paragraph(content: readonly AdfNode[]): AdfNode {
|
||||
return { content: [...content], type: 'paragraph' }
|
||||
}
|
||||
|
||||
function text(value: string): AdfNode {
|
||||
return { text: value, type: 'text' }
|
||||
}
|
||||
|
||||
function listOf(items: readonly AdfNode[], type: string): AdfNode[] {
|
||||
return items.length === 0 ? [] : [{ content: [...items], type }]
|
||||
}
|
||||
|
||||
function paragraphOf(nodes: readonly AdfNode[], reduction: Reduction): Result<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([{ content: blocks.value, type: 'listItem' }]) : blocks
|
||||
}
|
||||
|
||||
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({ content: previous === undefined ? blocks.value : mergedLists([...nodeContent(previous), ...blocks.value]), type: 'listItem' })
|
||||
}
|
||||
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)
|
||||
if (!blocks.ok) return blocks
|
||||
const [first, ...rest] = blocks.value
|
||||
if (first?.type === 'paragraph') return success([paragraph([text(`${marker} `), ...nodeContent(first)]), ...rest])
|
||||
return success([paragraph([text(marker)]), ...blocks.value])
|
||||
}
|
||||
|
||||
function reduceTable(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const grid: AdfNode[][] = []
|
||||
for (const [rowIndex, row] of nodeContent(node).entries()) {
|
||||
const rowReduction = childReduction(reduction, rowIndex)
|
||||
const cells = concatenated((row.type === 'tableRow' ? nodeContent(row) : [row]).map((cell, cellIndex) => cellParagraph(cell, childReduction(rowReduction, cellIndex))))
|
||||
if (!cells.ok) return cells
|
||||
grid.push(cells.value)
|
||||
}
|
||||
const width = grid.reduce((widest, cells) => Math.max(widest, cells.length), 0)
|
||||
const rows = grid.map((cells, rowIndex) => {
|
||||
const padded = [...cells, ...Array.from({ length: width - cells.length }, (): AdfNode => ({ type: 'paragraph' }))]
|
||||
return { content: padded.map((cell): AdfNode => ({ content: [cell], type: rowIndex === 0 ? 'tableHeader' : 'tableCell' })), type: 'tableRow' }
|
||||
})
|
||||
return success(width === 0 ? [] : [{ content: rows, type: 'table' }])
|
||||
}
|
||||
|
||||
function cellParagraph(cell: AdfNode, reduction: Reduction): Result<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 imageOrAlt(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' ? held.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ').trim() : ''
|
||||
const image: AdfNode = { attrs: { layout: 'center' }, content: [{ attrs: alt === '' ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' }], type: 'mediaSingle' }
|
||||
return commonMarkSpelling(image, reduction.path, reduction.depth, reduction.memo)?.ok === true ? success([image]) : paragraphOfNode(media, reduction)
|
||||
}
|
||||
Reference in New Issue
Block a user