From af7658a8636814a773f26e4793da5af0db8d832f Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 28 Sep 2026 22:12:58 +0200 Subject: [PATCH] 35a - review: a title keeps its ==, an image beside a marker is refused as before --- src/markdown/parse/inline-content.ts | 3 ++- src/markdown/parse/markdown-to-adf.ts | 28 +++++++++++++++++---------- src/markdown/plain-conventions.ts | 4 ---- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 4e3b045..a30cf01 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -8,7 +8,7 @@ import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedA import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts' import { commonMarkLink, linkHref } from '../mark-spellings.ts' import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts' -import { editorHighlight, highlightDelimiter } from '../plain-conventions.ts' +import { highlightDelimiter } from '../plain-conventions.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { mergeAdjacentText, sameMarks } from '../../adf/editor-normal.ts' @@ -59,6 +59,7 @@ type Scan = { type SlotContent = { carry: boolean; nodes: AdfNode[] } const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included' +const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' } const hreflessLink = 'the link mark spells its href: this one spells none' const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content' const linkInLink = 'no link wraps a link: the [content] this one marks already holds one' diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 4d67227..40a464c 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -24,6 +24,7 @@ type Paragraph = Extract type Reading = { definitions: LinkDefinitions; flavour: Flavour; inExpand: boolean; memo: SpellingMemo } const documentStart: SourcePosition = { line: 1, offset: 0 } +const imageBesideMarker = 'an image fits only as a paragraph of its own: this one shares its paragraph with a marker' export function markdownToAdf(markdown: string): Result { return readDocument(markdown, 'lossless') @@ -98,7 +99,6 @@ function readBlock(block: Block, reading: Reading, path: ConvertErrorPath, depth } } -// The marker opens the paragraph's source, and whitespace or the line's end follows it. function markerLed(block: Block | undefined, read: (text: string) => T | undefined): { marker: T; position: SourcePosition; text: string } | undefined { if (block?.kind !== 'paragraph') return undefined const marker = read(block.text) @@ -107,7 +107,6 @@ function markerLed(block: Block | undefined, read: return text === '' || /^(?:[ \t\n]|\\\n)/.test(text) ? { marker, position: block.position, text } : undefined } -// The marker's line, less a hard break ending it, and the lines after. function markerLine(text: string): { line: string; rest: string } { const lineEnd = text.indexOf('\n') const line = lineEnd === -1 ? text : text.slice(0, lineEnd) @@ -125,18 +124,27 @@ function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErro if (led === undefined) return containerNode({ type: 'blockquote' }, blocks, reading, path, depth) const { folded, panelType } = led.marker const { line, rest } = markerLine(led.text) - if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, [...paragraphsOf(led.position, line, rest), ...body], reading, path, depth) - const title = contentNode({ type: 'paragraph' }, line, reading, path, 'paragraph') + if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readMarked(paragraphsOf(led.position, line, rest), body, reading, path, depth)) + const title = parseInlineContent(line, reading.definitions, path, 'paragraph', 'lossless') if (!title.ok) return title - const text = nodeContent(title.value).map((node) => node.text ?? '').join('') + if (title.value.image !== undefined) return failure('unmappable-image', imageBesideMarker, path) + const text = title.value.nodes.map((node) => node.text ?? '').join('') const type = reading.inExpand ? 'nestedExpand' : 'expand' - return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, [...paragraphsOf(led.position, rest), ...body], { ...reading, inExpand: true }, path, depth) + return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readMarked(paragraphsOf(led.position, rest), body, { ...reading, inExpand: true }, path, depth)) } // Atlassian's schema requires a panel and an expand to hold a block. -function filledNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result { - const built = containerNode(node, blocks, reading, path, depth) - return built.ok && built.value.content === undefined ? success({ ...built.value, content: [{ type: 'paragraph' }] }) : built +function filledNode(node: AdfNode, content: Result): Result { + if (!content.ok) return content + return success({ ...node, content: content.value.length === 0 ? [{ type: 'paragraph' }] : content.value }) +} + +// A paragraph split off a marker still refuses the image it held beside it. +function readMarked(marked: readonly Paragraph[], others: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result { + const read = readBlocks([...marked, ...others], reading, path, depth + 1) + if (!read.ok) return read + const image = read.value.slice(0, marked.length).findIndex((node) => node.type === 'mediaSingle') + return image === -1 ? read : failure('unmappable-image', imageBesideMarker, [...path, 'content', image]) } // A task list trailing an item's blocks stands beside it, as ADF nests one. @@ -149,7 +157,7 @@ function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertEr } const tasks: AdfNode[] = [] for (const [index, { marker, others, position, text }] of led.entries()) { - const read = readBlocks([...paragraphsOf(position, text.replace(/^(?:[ \t\n]|\\\n)+/, '')), ...others], reading, [...path, 'content', index], depth + 1) + const read = readMarked(paragraphsOf(position, text.replace(/^(?:[ \t\n]|\\\n)+/, '')), others, reading, [...path, 'content', index], depth) if (!read.ok) return read let beside = read.value.length while (read.value[beside - 1]?.type === 'taskList') beside -= 1 diff --git a/src/markdown/plain-conventions.ts b/src/markdown/plain-conventions.ts index fa19f88..04de876 100644 --- a/src/markdown/plain-conventions.ts +++ b/src/markdown/plain-conventions.ts @@ -1,11 +1,7 @@ -import type { AdfMark } from '../adf/document.ts' - export type Flavour = 'lossless' | 'plain' type AlertMarker = { folded: boolean; length: number; panelType: string } -export const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' } - export const foldedAlertMarker = '[!NOTE]-' export const highlightDelimiter = '=='