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

Answer architecture round 1: 0 🔴, 4 🟡; share the quoting, carry list facts on the spelled list, keep inExpand in the portable reader

This commit is contained in:
2026-10-22 19:46:44 +02:00
committed by Lillem4n
parent 0c0a9e7835
commit 8f28d7bcc2
6 changed files with 47 additions and 47 deletions
+18 -20
View File
@@ -32,14 +32,14 @@ type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
export type PlacedBlock = SpelledBlock & { node: AdfNode }
export type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
// CommonMark starts a new list where the marker changes, so every second list in a run of one kind takes `alternateText`.
type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; spelling: 'list'; text: string }
type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; interruptsParagraph: boolean; ordered: boolean; 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 }
export type WrittenAs = { flavour: 'lossless' } | { blocks: FlavourBlockWriters; flavour: 'portable' }
export type Writing = WrittenAs & { memo: SpellingMemo | undefined }
export const bulletDelimiters: Delimiters = ['-', '*']
const bulletDelimiters: Delimiters = ['-', '*']
export const largestListMarker = 999999999
// Bare because tryList admits no item carrying attributes, marks or text.
const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
@@ -94,24 +94,16 @@ export function joinBlocks(blocks: readonly PlacedBlock[], container: BlockConta
return text
}
// 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')
return previous.spelling === 'list' && next.spelling === 'list' && previous.ordered === next.ordered
}
function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string {
const bothCommonMark = previous.spelling !== 'directive' && next.spelling !== 'directive'
if (bothCommonMark && next.spelling === 'list' && container === 'list-item') return interruptsParagraph(next.node) ? '\n' : '\n\n'
if (bothCommonMark && next.spelling === 'list' && container === 'list-item') return next.interruptsParagraph ? '\n' : '\n\n'
return container === 'directive' && !bothCommonMark ? '\n' : '\n\n'
}
function interruptsParagraph(node: AdfNode): boolean {
const items = nodeContent(node)
const empty = node.type !== 'taskList' && (items[0] === undefined || nodeContent(items[0]).length === 0)
if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty)
return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty)
}
export function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const model = blockNodeModel(node.type)
if (model === undefined) return carriedFence(carriedBlock(node, path, depth))
@@ -160,7 +152,7 @@ function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number
return undefined
}
export function quoted(text: string): string {
function quoted(text: string): string {
return text
.split('\n')
.map((line) => (line === '' ? '>' : `> ${line}`))
@@ -176,7 +168,7 @@ function carriedFence(carried: Result<{ headroom: number; text: string }>): Resu
return success({ ...carried.value, spelling: 'commonmark' })
}
export function commonMarkText(text: string, headroom: number = Number.POSITIVE_INFINITY): EmittedBlock {
function commonMarkText(text: string, headroom: number = Number.POSITIVE_INFINITY): EmittedBlock {
return { headroom, spelling: 'commonmark', text }
}
@@ -208,11 +200,13 @@ function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string,
function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
if (!holdsOnlyAttributes(node, [])) return undefined
return quotedBlocks(node, path, depth, writing, (body) => (writing.flavour === 'portable' && writing.blocks.claimsQuote(body) ? `\\${body}` : body))
}
export function quotedBlocks(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing, text: (body: string) => string): Result<EmittedBlock> {
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
if (!inner.ok) return inner
const text = joinBlocks(inner.value.blocks, 'document')
const alert = writing.flavour === 'portable' && writing.blocks.claimsQuote(text)
return success(commonMarkText(quoted(alert ? `\\${text}` : text), inner.value.headroom))
return success(commonMarkText(quoted(text(joinBlocks(inner.value.blocks, 'document'))), inner.value.headroom))
}
function tryCodeBlock(node: AdfNode, flavour: WrittenFlavour): string | undefined {
@@ -278,17 +272,21 @@ function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing:
const inner = joinBlocks(item.walk.blocks, 'list-item')
return writing.flavour === 'portable' && writing.blocks.claimsItem(inner) ? `\\${inner}` : inner
})
const list = ordered ? spelledList(inners, (offset) => `${start + offset}`, orderedDelimiters, headroom) : spelledList(inners, () => '', bulletDelimiters, headroom)
const list = spelledList(inners, ordered ? start : undefined, headroom)
if (list !== undefined) return success(list)
// The directive form spends a level the walk did not count.
if (headroom < 1) return tooDeep(path)
return emitDirectiveBlock(node, ordered ? blockNodes.orderedList : blockNodes.bulletList, path, depth, () => success({ blocks: directiveItems(walked), headroom: headroom - 1 }))
}
export function spelledList(inners: readonly string[], number: (offset: number) => string, [usual, alternate]: Delimiters, headroom: number): EmittedBlock | undefined {
// start: an ordered list's first number, undefined for a bullet list.
export function spelledList(inners: readonly string[], start: number | undefined, headroom: number): EmittedBlock | undefined {
const [usual, alternate] = start === undefined ? bulletDelimiters : orderedDelimiters
const number = (offset: number): string => (start === undefined ? '' : `${start + offset}`)
const text = listLines(inners, number, usual)
const alternateText = listLines(inners, number, alternate)
return text === undefined || alternateText === undefined ? undefined : { alternateText, headroom, spelling: 'list', text }
if (text === undefined || alternateText === undefined) return undefined
return { alternateText, headroom, interruptsParagraph: markerInterruptsParagraph(start, inners[0] === ''), ordered: start !== undefined, spelling: 'list', text }
}
function listLines(inners: readonly string[], number: (offset: number) => string, delimiter: string): string | undefined {
+2 -1
View File
@@ -17,7 +17,8 @@ type WrittenClaims = Claims & { pipeTables: 'flavour' | 'gfm'; strikethrough: tr
// Directives exclude highlights: a directive's slot reads `==`.
type ReadClaims = Claims & ({ directives: false } | { directives: true; highlights: false })
export const flavourClaims: Readonly<{ commonmark: ReadClaims } & Record<WrittenFlavour, ReadClaims & WrittenClaims>> = {
// A folded callout inside an expand directive would read as an expand, not a nestedExpand, so the portable flavour reads no directives.
export const flavourClaims: Readonly<{ commonmark: ReadClaims; portable: { directives: false } } & Record<WrittenFlavour, ReadClaims & WrittenClaims>> = {
commonmark: { carryFence: false, directives: false, highlights: false, pipeTables: false, strikethrough: false },
lossless: { carryFence: true, directives: true, highlights: false, pipeTables: 'flavour', strikethrough: true },
portable: { carryFence: false, directives: false, highlights: true, pipeTables: 'gfm', strikethrough: true },
+6 -5
View File
@@ -21,14 +21,15 @@ import { withinEngineLimits } from '../length-bound.ts'
// A flavour's own reading of a quote and of a bullet list, which may read to several blocks.
export type FlavourBlockReaders = {
bulletList: (items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number, listIndex: number) => Result<AdfNode[]>
quote: (blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number) => Result<AdfNode>
bulletList: (items: readonly Block[][], reading: FlavourReading, path: ConvertErrorPath, depth: number, listIndex: number) => Result<AdfNode[]>
quote: (blocks: readonly Block[], reading: FlavourReading, path: ConvertErrorPath, depth: number) => Result<AdfNode>
}
export type FlavourReading = Extract<Reading, { flavour: 'portable' }>
export type ReadAs = { flavour: 'commonmark' | 'lossless' } | { blocks: FlavourBlockReaders; flavour: 'portable' }
// `inExpand` is whether an expand holds the blocks, which makes a folded callout a nestedExpand. An expand directive leaves it unset, so no flavour reads both.
export type Reading = ReadAs & { definitions: LinkDefinitions; inExpand: boolean; memo: SpellingMemo }
export type Reading = ReadAs & { definitions: LinkDefinitions; memo: SpellingMemo }
export function commonMarkToAdf(markdown: string): Result<AdfDocument, ParseError> {
return readDocument(markdown, { flavour: 'commonmark' })
@@ -53,7 +54,7 @@ function readMarkdown(markdown: string, readAs: ReadAs): Result<AdfDocument> {
const fault = documentFault(only)
return fault === undefined ? success({ type: 'doc', version: 1 }) : positioned(faulted(fault, []), only.position)
}
const reading: Reading = { ...readAs, definitions: parsed.definitions, inExpand: false, memo: new Map() }
const reading: Reading = { ...readAs, definitions: parsed.definitions, memo: new Map() }
const content = readBlocks(parsed.blocks, reading, [], 0)
return content.ok ? success({ content: content.value, type: 'doc', version: 1 }) : content
}
@@ -11,7 +11,7 @@ import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { linkDefinition } from '../mark-spellings.ts'
import { longestMarkdown, tooLong, withinEngineLimits } from '../length-bound.ts'
import { portableBlockWriters } from './block-spelling.ts'
import { portableWriting } from './block-spelling.ts'
import { splitTaskList } from './task-list.ts'
import { taskMarker } from './conventions.ts'
import { toEditorNormal } from './editor-normal.ts'
@@ -54,7 +54,7 @@ const blockReducers: Readonly<Record<string, BlockReducer>> = {
export function adfToPortableMarkdown(document: AdfDocument): Result<string> {
return withinEngineLimits(() => {
const reduced = reduceToPortable(document)
return reduced.ok ? writeMarkdown(reduced.value, { blocks: portableBlockWriters, flavour: 'portable' }) : reduced
return reduced.ok ? writeMarkdown(reduced.value, portableWriting) : reduced
})
}
@@ -133,7 +133,7 @@ function portableSequence(blocks: readonly Sourced[], reduction: Reduction): Res
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
const block = numberedPastMarkers(next.block)
const listed = block.type === 'bulletList' || block.type === 'orderedList'
const spelled = listed && block === next.block ? commonMarkSpelling(block, next.path, reduction.depth, { blocks: portableBlockWriters, flavour: 'portable', memo: reduction.memo }) : success(!listed)
const spelled = listed && block === next.block ? commonMarkSpelling(block, next.path, reduction.depth, { ...portableWriting, memo: reduction.memo }) : success(!listed)
if (!spelled.ok) return spelled
if (spelled.value) {
sequence.push(block)
+7 -13
View File
@@ -1,21 +1,23 @@
import type { AdfNode } from '../../adf/document.ts'
import type { ConvertErrorPath, Result } from '../../result.ts'
import type { EmittedBlock, FlavourBlockWriters, PlacedBlock, PlacedBlocks, Writing } from '../emit/adf-to-markdown.ts'
import type { EmittedBlock, FlavourBlockWriters, PlacedBlock, PlacedBlocks, Writing, WrittenAs } from '../emit/adf-to-markdown.ts'
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from './conventions.ts'
import { bulletDelimiters, commonMarkText, emitBlock, joinBlocks, quoted, spelledList, walkBlocks } from '../emit/adf-to-markdown.ts'
import { emitBlock, joinBlocks, quotedBlocks, spelledList, walkBlocks } from '../emit/adf-to-markdown.ts'
import { emitInlineLine } from '../emit/inline-line.ts'
import { failure, success } from '../../result.ts'
import { isTaskItem } from '../../adf/block-nodes.ts'
import { largestNesting } from '../../nesting.ts'
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
export const portableBlockWriters: FlavourBlockWriters = {
const portableBlockWriters: FlavourBlockWriters = {
// GitHub reads a task marker opening any item's first paragraph as a checkbox, whatever its siblings hold.
claimsItem: (text) => leadingMarker(text, readTaskMarker) !== undefined,
claimsQuote: (text) => leadingMarker(text, readAlertMarker) !== undefined,
spell: spellPortableBlock,
}
export const portableWriting: WrittenAs = { blocks: portableBlockWriters, flavour: 'portable' }
// The portable flavour's nodes, in the shapes the portable reduction leaves them.
function spellPortableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
if (node.type === 'panel') return quotedUnder(alertMarker(nodeAttrs(node)['panelType']), node, path, depth, writing)
@@ -28,10 +30,7 @@ function spellPortableBlock(node: AdfNode, path: ConvertErrorPath, depth: number
}
function quotedUnder(head: string, node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
if (!inner.ok) return inner
const body = joinBlocks(inner.value.blocks, 'document')
return success(commonMarkText(quoted(body === '' ? head : `${head}\n\n${body}`), inner.value.headroom))
return quotedBlocks(node, path, depth, writing, (body) => (body === '' ? head : `${head}\n\n${body}`))
}
function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
@@ -47,12 +46,7 @@ function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writi
if (task || previous === undefined) items.push([...walk.value.blocks])
else for (const block of walk.value.blocks) previous.push(block)
}
const list = spelledList(
items.map((blocks) => joinBlocks(blocks, 'list-item')),
() => '',
bulletDelimiters,
headroom,
)
const list = spelledList(items.map((blocks) => joinBlocks(blocks, 'list-item')), undefined, headroom)
// The portable reduction leaves no task a list item cannot hold: a directive here would break the flavour.
return list === undefined ? failure('unsupported-node-shape', 'a task holds blocks no list item spells', path) : success(list)
}
@@ -1,7 +1,7 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block } from '../parse/blocks.ts'
import type { ConvertError, ConvertErrorPath, ParseError, Result, SourcePosition } from '../../result.ts'
import type { FlavourBlockReaders, Reading } from '../parse/markdown-to-adf.ts'
import type { FlavourBlockReaders, FlavourReading, Reading } from '../parse/markdown-to-adf.ts'
import { containerNode, contentNode, listNode, readBlocks, readDocument, tooDeep } from '../parse/markdown-to-adf.ts'
import { largestNesting } from '../../nesting.ts'
import { leadingMarker, readAlertMarker, readTaskMarker } from './conventions.ts'
@@ -15,7 +15,13 @@ type Paragraph = Extract<Block, { kind: 'paragraph' }>
// The paragraph a task marker opens: a task item holds no image, so a lone one there reads as its linked alt text.
type TaskParagraph = { kind: 'taskParagraph'; position: SourcePosition; text: string }
const portableBlockReaders: FlavourBlockReaders = { bulletList: bulletBlocks, quote: quoteNode }
// inExpand: whether an expand holds the blocks, which makes a folded callout a nestedExpand.
function blockReaders(inExpand: boolean): FlavourBlockReaders {
return { bulletList: bulletBlocks, quote: (blocks, reading, path, depth) => quoteNode(blocks, reading, path, depth, inExpand) }
}
const expandBlockReaders = blockReaders(true)
const portableBlockReaders = blockReaders(false)
export function portableMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
const read = readDocument(markdown, { blocks: portableBlockReaders, flavour: 'portable' })
@@ -41,7 +47,7 @@ function paragraphsOf(position: SourcePosition, ...texts: string[]): Paragraph[]
return texts.filter((text) => text !== '').map((text) => ({ kind: 'paragraph', position, text }))
}
function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
function quoteNode(blocks: readonly Block[], reading: FlavourReading, path: ConvertErrorPath, depth: number, inExpand: boolean): Result<AdfNode> {
const [first, ...body] = blocks
const led = markerLed(first, readAlertMarker)
if (led === undefined) return containerNode({ type: 'blockquote' }, blocks, reading, path, depth)
@@ -51,8 +57,8 @@ function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErro
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.flavour)
if (!title.ok) return title
const text = titleText(title.value.nodes)
const type = reading.inExpand ? 'nestedExpand' : 'expand'
return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readBlocks([...paragraphsOf(led.position, rest), ...body], { ...reading, inExpand: true }, path, depth + 1))
const type = inExpand ? 'nestedExpand' : 'expand'
return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readBlocks([...paragraphsOf(led.position, rest), ...body], { ...reading, blocks: expandBlockReaders }, path, depth + 1))
}
// docs/decisions.md, A callout title keeps its link targets.