From 8f28d7bcc283bbd6d23a612d0829009d125676e6 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 22 Oct 2026 19:46:44 +0200 Subject: [PATCH] =?UTF-8?q?Answer=20architecture=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=204=20=F0=9F=9F=A1;=20share=20the=20quoting,=20ca?= =?UTF-8?q?rry=20list=20facts=20on=20the=20spelled=20list,=20keep=20inExpa?= =?UTF-8?q?nd=20in=20the=20portable=20reader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/markdown/emit/adf-to-markdown.ts | 38 +++++++++---------- src/markdown/flavour.ts | 3 +- src/markdown/parse/markdown-to-adf.ts | 11 +++--- .../portable/adf-to-portable-markdown.ts | 6 +-- src/markdown/portable/block-spelling.ts | 20 ++++------ .../portable/portable-markdown-to-adf.ts | 16 +++++--- 6 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 9b95904..13cc248 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -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 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 { 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 | 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 { 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 { diff --git a/src/markdown/flavour.ts b/src/markdown/flavour.ts index e7b3cad..458eb97 100644 --- a/src/markdown/flavour.ts +++ b/src/markdown/flavour.ts @@ -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> = { +// 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> = { 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 }, diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 11c0771..cad6610 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -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 - quote: (blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number) => Result + bulletList: (items: readonly Block[][], reading: FlavourReading, path: ConvertErrorPath, depth: number, listIndex: number) => Result + quote: (blocks: readonly Block[], reading: FlavourReading, path: ConvertErrorPath, depth: number) => Result } +export type FlavourReading = Extract + 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 { return readDocument(markdown, { flavour: 'commonmark' }) @@ -53,7 +54,7 @@ function readMarkdown(markdown: string, readAs: ReadAs): Result { 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 } diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index 5473381..b9f6d39 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -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> = { export function adfToPortableMarkdown(document: AdfDocument): Result { 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) diff --git a/src/markdown/portable/block-spelling.ts b/src/markdown/portable/block-spelling.ts index 0b2dcd2..14e3e37 100644 --- a/src/markdown/portable/block-spelling.ts +++ b/src/markdown/portable/block-spelling.ts @@ -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 | 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 { - 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 { @@ -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) } diff --git a/src/markdown/portable/portable-markdown-to-adf.ts b/src/markdown/portable/portable-markdown-to-adf.ts index 58a8e0e..f95a684 100644 --- a/src/markdown/portable/portable-markdown-to-adf.ts +++ b/src/markdown/portable/portable-markdown-to-adf.ts @@ -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 // 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 { 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 { +function quoteNode(blocks: readonly Block[], reading: FlavourReading, path: ConvertErrorPath, depth: number, inExpand: boolean): Result { 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.