3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-13 04:08:29 +01:00

Answer architecture round 2: portableSequence walks a stack in linear time, the * escape and the alternation are worded and named for what they do, item 51 names no count, and item 94 files the link-definition join

This commit is contained in:
2026-10-09 15:05:04 +02:00
committed by Lillem4n
parent 49ec61709c
commit c2b4bea2dc
5 changed files with 46 additions and 31 deletions
+9 -8
View File
@@ -20,13 +20,13 @@ import { tryPipeTable } from './pipe-table.ts'
type BlockContainer = 'directive' | 'document' | 'list-item'
// The usual delimiter, then the one a list beside another of its kind takes.
type Delimiters = readonly [string, string]
// CommonMark starts a new list where the marker changes, so a list beside one of its kind takes `alternateText`.
type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; spelling: 'list'; text: string }
// headroom: the nesting levels the deepest node below may still spend before `largestNesting` refuses it.
type EmittedBlock = SpelledBlock & { headroom: number }
type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
type PlacedBlock = SpelledBlock & { node: AdfNode }
type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
// CommonMark starts a new list where the marker changes, so a list beside one of its kind takes `alternateText`.
type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; spelling: 'list'; text: string }
// Keyed by reference: only a caller building one object per position (the parse, the portable reduction) passes one; a consumer's document may share a node.
export type SpellingMemo = Map<AdfNode, KeptSpelling>
type WalkedItem = { node: AdfNode; walk: PlacedBlocks }
@@ -34,9 +34,9 @@ export type Writing = { flavour: WrittenFlavour; memo: SpellingMemo | undefined
const bulletDelimiters: Delimiters = ['-', '*']
export const largestListMarker = 999999999
const orderedDelimiters: Delimiters = ['.', ')']
// Bare because tryList admits no item carrying attributes, marks or text.
const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
const orderedDelimiters: Delimiters = ['.', ')']
export function adfToLosslessMarkdown(document: AdfDocument): Result<string> {
return writeMarkdown(document, 'lossless')
@@ -76,14 +76,15 @@ function joinBlocks(blocks: readonly PlacedBlock[], container: BlockContainer):
for (const [index, block] of blocks.entries()) {
const previous = blocks[index - 1]
if (previous !== undefined) text += separationBetween(previous, block, container)
alternated = !alternated && previous?.spelling === 'list' && block.spelling === 'list' && isOrdered(previous.node) === isOrdered(block.node)
alternated = !alternated && previous !== undefined && sharesMarker(previous, block)
text += alternated && block.spelling === 'list' ? block.alternateText : block.text
}
return text
}
function isOrdered(node: AdfNode): boolean {
return node.type === 'orderedList'
// A task list is spelled as a bullet list.
function sharesMarker(previous: PlacedBlock, next: PlacedBlock): boolean {
return previous.spelling === 'list' && next.spelling === 'list' && (previous.node.type === 'orderedList') === (next.node.type === 'orderedList')
}
function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string {
@@ -208,7 +209,7 @@ function taskBlocks(task: AdfNode, path: ConvertErrorPath, depth: number, writin
const walk = walkBlocks(nodeContent(task), path, depth, writing)
if (!walk.ok) return walk
const [first, ...rest] = walk.value.blocks
const blocks = first?.node.type === 'paragraph' ? [{ ...first, text: `${marker} ${first.text}` }, ...rest] : [markerBlock, ...walk.value.blocks]
const blocks = first?.node.type === 'paragraph' && first.spelling !== 'list' ? [{ ...first, text: `${marker} ${first.text}` }, ...rest] : [markerBlock, ...walk.value.blocks]
return success({ blocks, headroom: walk.value.headroom })
}
@@ -366,7 +367,7 @@ function tryListItemLines(inner: string, marker: string): string | undefined {
const lines = body.map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`))
const first = lines[0] ?? ''
if (!isThematicBreak(first)) return lines.join('\n')
// A first line of `*` alone beside the `* ` marker is text, since a rule is spelled `---`: a backslash keeps it from reading as one.
// Only text opens an item with `*`, a rule being spelled `---`; where `* ` before it reads as a rule, a backslash keeps it text.
return inner.startsWith('*') ? [`${marker}\\${first.slice(marker.length)}`, ...lines.slice(1)].join('\n') : undefined
}
@@ -116,16 +116,18 @@ function concatenated(results: readonly Result<AdfNode[]>[]): Result<AdfNode[]>
// A list still taking the directive form gives way to its items' blocks.
function portableSequence(blocks: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
let sequence = 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, { flavour: 'portable', memo: reduction.memo })?.ok === true
if (spelled) continue
sequence = [...sequence.slice(0, index), ...(block === listed ? nodeContent(block).flatMap(nodeContent) : [block]), ...sequence.slice(index + 1)]
// The replacement's first block takes this index, and may itself take the directive form.
index -= 1
const pending = blocks.filter((block) => block.type !== 'paragraph' || nodeContent(block).length > 0).reverse()
const sequence: AdfNode[] = []
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
const block = numberedPastMarkers(next)
const listed = block.type === 'bulletList' || block.type === 'orderedList'
if (!listed || (block === next && commonMarkSpelling(block, reduction.path, reduction.depth, { flavour: 'portable', memo: reduction.memo })?.ok === true)) {
sequence.push(block)
continue
}
// Read next, since a replacement may take the directive form itself.
const replacement = block === next ? nodeContent(block).flatMap(nodeContent) : [block]
for (const node of replacement.reverse()) pending.push(node)
}
return success(sequence)
}