From 53ff7e0204b19d0b48e26854341dd46584e20e9c Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 25 Sep 2026 20:52:44 +0200 Subject: [PATCH] 10b - the lift, with alert words, task markers and == shared with the reduction --- src/markdown/emit/plain-inline.ts | 2 +- src/markdown/emit/plain-reduction.ts | 21 +--- src/markdown/parse/plain-lift.ts | 163 +++++++++++++++++++++++++++ src/markdown/plain-conventions.ts | 54 +++++++++ 4 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 src/markdown/parse/plain-lift.ts create mode 100644 src/markdown/plain-conventions.ts diff --git a/src/markdown/emit/plain-inline.ts b/src/markdown/emit/plain-inline.ts index f4f3b09..007ee63 100644 --- a/src/markdown/emit/plain-inline.ts +++ b/src/markdown/emit/plain-inline.ts @@ -2,6 +2,7 @@ import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts' import type { LineContainer } from '../line-container.ts' import { blockNodeModel } from '../../adf/block-nodes.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' +import { highlightDelimiter } from '../plain-conventions.ts' import { largestNesting } from '../../nesting.ts' import { mergeAdjacentText, sameMark } from '../../adf/editor-normal.ts' import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' @@ -9,7 +10,6 @@ import { plainLineFallback, type PlainLineFallback } from './inline-line.ts' import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' const highlight = 'backgroundColor' -const highlightDelimiter = '==' const edgeStrippingMarks: readonly string[] = [highlight, 'em', 'strike', 'strong'] const keptMarks: readonly string[] = [...edgeStrippingMarks, 'code', 'link'] diff --git a/src/markdown/emit/plain-reduction.ts b/src/markdown/emit/plain-reduction.ts index a66be46..33e935b 100644 --- a/src/markdown/emit/plain-reduction.ts +++ b/src/markdown/emit/plain-reduction.ts @@ -1,5 +1,6 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts' +import { alertMarker, foldedAlertMarker, taskMarker } from '../plain-conventions.ts' import { blockNodeModel } from '../../adf/block-nodes.ts' import { commonMarkSpelling, largestListMarker, type SpellingMemo } from './adf-to-markdown.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' @@ -17,15 +18,6 @@ type PlacedCell = { colspan: number; paragraph: AdfNode; rowspan: number } type Placed = { index: number; loose: AdfNode[] } | { index: number; loose?: undefined; node: AdfNode } -const alertWords: Readonly> = { - error: 'CAUTION', - info: 'NOTE', - note: 'IMPORTANT', - success: 'TIP', - tip: 'TIP', - warning: 'WARNING', -} - const blockReducers: Readonly> = { blockCard: paragraphOfNode, blockquote: (node, reduction) => quoted(success([]), node, reduction), @@ -41,7 +33,7 @@ const blockReducers: Readonly> = { mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index) => reduceStanding(child, childReduction(reduction, index)))), nestedExpand: reduceExpand, orderedList: reduceList, - panel: (node, reduction) => quoted(success([paragraph([text(`[!${alertWord(nodeAttrs(node)['panelType'])}]`)])]), node, reduction), + panel: (node, reduction) => quoted(success([paragraph([text(alertMarker(nodeAttrs(node)['panelType']))])]), node, reduction), paragraph: (node, reduction) => paragraphOf(nodeContent(node), reduction), rule: () => success([{ type: 'rule' }]), syncBlock: paragraphOfNode, @@ -190,14 +182,9 @@ function quoted(head: Result, node: AdfNode, reduction: Reduction): R 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 { const title = nodeAttrs(node)['title'] - const marker = typeof title === 'string' ? `[!NOTE]- ${title.replace(/^[ \t\n\r]+/, '')}` : '[!NOTE]-' + const marker = typeof title === 'string' ? `${foldedAlertMarker} ${title.replace(/^[ \t\n\r]+/, '')}` : foldedAlertMarker return quoted(paragraphOf([text(marker)], { ...reduction, depth: reduction.depth + 1 }), node, reduction) } @@ -257,7 +244,7 @@ function reduceTaskList(node: AdfNode, reduction: Reduction): Result } function taskBlocks(child: AdfNode, at: Reduction): Result { - const marker = nodeAttrs(child)['state'] === 'DONE' ? '[x]' : '[ ]' + const marker = taskMarker(nodeAttrs(child)['state']) 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 diff --git a/src/markdown/parse/plain-lift.ts b/src/markdown/parse/plain-lift.ts new file mode 100644 index 0000000..4559956 --- /dev/null +++ b/src/markdown/parse/plain-lift.ts @@ -0,0 +1,163 @@ +import type { AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' +import { blockNodeModel } from '../../adf/block-nodes.ts' +import { highlightDelimiter, readAlertMarker, readTaskMarker } from '../plain-conventions.ts' +import { mergeAdjacentText, sameMark } from '../../adf/editor-normal.ts' +import { nodeContent, nodeMarks } from '../../adf/document.ts' + +type Delimiter = { closes: boolean; line: number; marks: readonly AdfMark[]; node: number; offset: number; opens: boolean; position: number } + +type MarkerLed = { marker: T; rest: AdfNode[] } + +const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' } + +export function liftFromPlain(document: AdfDocument): AdfDocument { + return { ...document, content: liftBlocks(nodeContent(document), false) } +} + +function liftBlocks(blocks: readonly AdfNode[], inExpand: boolean): AdfNode[] { + return blocks.map((block) => liftBlock(block, inExpand)) +} + +function liftBlock(block: AdfNode, inExpand: boolean): AdfNode { + const model = blockNodeModel(block.type)?.contentModel + if (model === 'inline') return { ...block, content: highlighted(nodeContent(block)) } + if (model !== 'block') return block + if (block.type === 'blockquote') return liftQuote(block, inExpand) + const tasks = block.type === 'bulletList' ? taskItems(nodeContent(block), inExpand) : undefined + if (tasks !== undefined) return { content: tasks, type: 'taskList' } + return { ...block, content: liftBlocks(nodeContent(block), inExpand || block.type === 'expand' || block.type === 'nestedExpand') } +} + +function paragraphOf(content: readonly AdfNode[]): AdfNode[] { + return content.length === 0 ? [] : [{ content: [...content], type: 'paragraph' }] +} + +// The marker opens the block's unmarked text and whitespace or the line's end follows it. +function markerLed(block: AdfNode | undefined, read: (text: string) => T | undefined): MarkerLed | undefined { + if (block?.type !== 'paragraph') return undefined + const [first, ...others] = nodeContent(block) + if (first?.type !== 'text' || first.text === undefined || nodeMarks(first).length > 0) return undefined + const marker = read(first.text) + if (marker === undefined) return undefined + const tail = first.text.slice(marker.length) + const lineEnds = tail === '' && (others[0] === undefined || others[0].type === 'hardBreak') + if (!lineEnds && !/^[ \t]/.test(tail)) return undefined + const trimmed = tail.replace(/^[ \t]+/, '') + const rest = trimmed === '' ? others : [{ text: trimmed, type: 'text' }, ...others] + let from = 0 + while (rest[from]?.type === 'hardBreak') from += 1 + return { marker, rest: rest.slice(from) } +} + +function liftQuote(quote: AdfNode, inExpand: boolean): AdfNode { + const [first, ...body] = nodeContent(quote) + const led = markerLed(first, readAlertMarker) + if (led === undefined) return { ...quote, content: liftBlocks(nodeContent(quote), inExpand) } + if (!led.marker.folded) return { attrs: { panelType: led.marker.panelType }, content: liftBlocks([...paragraphOf(led.rest), ...body], inExpand), type: 'panel' } + const title = led.rest.map((node) => node.text ?? (node.type === 'hardBreak' ? '\n' : '')).join('') + const content = liftBlocks(body, true) + const type = inExpand ? 'nestedExpand' : 'expand' + return title === '' ? { content, type } : { attrs: { title }, content, type } +} + +// A task list trailing an item's blocks stands beside it, as ADF nests one. +function taskItems(items: readonly AdfNode[], inExpand: boolean): AdfNode[] | undefined { + if (items.length === 0) return undefined + const tasks: AdfNode[] = [] + for (const item of items) { + const led = item.type === 'listItem' ? markerLed(nodeContent(item)[0], readTaskMarker) : undefined + if (led === undefined) return undefined + const blocks = liftBlocks([...paragraphOf(led.rest), ...nodeContent(item).slice(1)], inExpand) + let beside = blocks.length + while (blocks[beside - 1]?.type === 'taskList') beside -= 1 + const kept = blocks.slice(0, beside) + const [only] = kept + const attrs = { state: led.marker.state } + const inline = kept.length <= 1 && (only === undefined || only.type === 'paragraph') + tasks.push(inline ? { attrs, content: [...nodeContent(only ?? {})], type: 'taskItem' } : { attrs, content: kept, type: 'blockTaskItem' }) + for (const nested of blocks.slice(beside)) tasks.push(nested) + } + return tasks +} + +function highlighted(inline: readonly AdfNode[]): AdfNode[] { + const pairs = pairedDelimiters(inline) + return pairs.length === 0 ? [...inline] : mergeAdjacentText(split(inline, pairs)) +} + +function textOf(node: AdfNode | undefined): string | undefined { + return node?.type === 'text' ? node.text : undefined +} + +function delimiters(inline: readonly AdfNode[]): Delimiter[] { + const found: Delimiter[] = [] + let line = 0 + let position = 0 + for (const [index, node] of inline.entries()) { + const text = textOf(node) + if (text === undefined) line += 1 + if (text === undefined || nodeMarks(node).some((mark) => mark.type === 'code')) { + position += text?.length ?? 0 + continue + } + for (let offset = text.indexOf(highlightDelimiter); offset !== -1; offset = text.indexOf(highlightDelimiter, offset + highlightDelimiter.length)) { + const before = offset > 0 ? text[offset - 1] : textOf(inline[index - 1])?.at(-1) + const after = text[offset + highlightDelimiter.length] ?? textOf(inline[index + 1])?.[0] + found.push({ closes: flanks(before), line, marks: nodeMarks(node), node: index, offset, opens: flanks(after), position: position + offset }) + } + position += text.length + } + return found +} + +function flanks(character: string | undefined): boolean { + return character !== undefined && !/\s/.test(character) +} + +// Each opener takes the next closer holding at least one character after it, both in one line and under the same marks. +function pairedDelimiters(inline: readonly AdfNode[]): Delimiter[] { + const found = delimiters(inline) + const paired: Delimiter[] = [] + let closer = 0 + let resume = 0 + for (const opener of found) { + if (!opener.opens || opener.position < resume) continue + const earliest = opener.position + highlightDelimiter.length + 1 + let candidate = found[closer] + while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) candidate = found[(closer += 1)] + if (candidate === undefined) break + if (candidate.line !== opener.line || !sameMarks(opener.marks, candidate.marks)) continue + paired.push(opener, candidate) + resume = candidate.position + highlightDelimiter.length + } + return paired +} + +function sameMarks(first: readonly AdfMark[], second: readonly AdfMark[]): boolean { + return first.length === second.length && first.every((mark, index) => second[index] !== undefined && sameMark(mark, second[index])) +} + +function split(inline: readonly AdfNode[], paired: readonly Delimiter[]): AdfNode[] { + const lifted: AdfNode[] = [] + let next = 0 + for (const [index, node] of inline.entries()) { + const text = textOf(node) + if (text === undefined) { + lifted.push(node) + continue + } + let from = 0 + for (let delimiter = paired[next]; delimiter?.node === index; delimiter = paired[next]) { + pushPiece(lifted, node, text.slice(from, delimiter.offset), next % 2 === 1) + from = delimiter.offset + highlightDelimiter.length + next += 1 + } + pushPiece(lifted, node, text.slice(from), next % 2 === 1) + } + return lifted +} + +function pushPiece(lifted: AdfNode[], node: AdfNode, text: string, inPair: boolean): void { + const marks = inPair ? [editorHighlight, ...nodeMarks(node)] : [...nodeMarks(node)] + if (text !== '') lifted.push(marks.length === 0 ? { text, type: 'text' } : { marks, text, type: 'text' }) +} diff --git a/src/markdown/plain-conventions.ts b/src/markdown/plain-conventions.ts new file mode 100644 index 0000000..d972f0d --- /dev/null +++ b/src/markdown/plain-conventions.ts @@ -0,0 +1,54 @@ +type AlertMarker = { folded: boolean; length: number; panelType: string } + +export const foldedAlertMarker = '[!NOTE]-' +export const highlightDelimiter = '==' + +const alertWords: Readonly> = { + error: 'CAUTION', + info: 'NOTE', + note: 'IMPORTANT', + success: 'TIP', + tip: 'TIP', + warning: 'WARNING', +} + +const panelTypesByWord: Readonly> = { + attention: 'warning', + bug: 'error', + caution: 'error', + check: 'success', + danger: 'error', + done: 'success', + error: 'error', + fail: 'error', + failure: 'error', + hint: 'tip', + important: 'note', + missing: 'error', + success: 'success', + tip: 'tip', + warning: 'warning', +} + +export function alertMarker(panelType: unknown): string { + const word = typeof panelType === 'string' && Object.hasOwn(alertWords, panelType) ? alertWords[panelType] : undefined + return `[!${word ?? 'NOTE'}]` +} + +export function readAlertMarker(text: string): AlertMarker | undefined { + const marker = /^\[!([\w-]+)\]([+-]?)/.exec(text) + if (marker === null) return undefined + const word = (marker[1] ?? '').toLowerCase() + const panelType = Object.hasOwn(panelTypesByWord, word) ? panelTypesByWord[word] : undefined + return { folded: marker[2] !== '', length: marker[0].length, panelType: panelType ?? 'info' } +} + +export function taskMarker(state: unknown): string { + return state === 'DONE' ? '[x]' : '[ ]' +} + +export function readTaskMarker(text: string): { length: number; state: 'DONE' | 'TODO' } | undefined { + const marker = /^\[([ xX])\]/.exec(text) + if (marker === null) return undefined + return { length: marker[0].length, state: marker[1] === ' ' ? 'TODO' : 'DONE' } +}