Compare commits

..

2 Commits

Author SHA1 Message Date
lilleman af7658a863 35a - review: a title keeps its ==, an image beside a marker is refused as before
CI / gate (push) Successful in 41s
CI / publish (push) Has been skipped
2026-09-28 22:12:58 +02:00
lilleman 13b04e46d3 35a - review: tests for an expand title's == and an image beside a marker 2026-09-28 22:12:58 +02:00
4 changed files with 26 additions and 15 deletions
+2 -1
View File
@@ -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'
+18 -10
View File
@@ -24,6 +24,7 @@ type Paragraph = Extract<Block, { kind: 'paragraph' }>
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<AdfDocument, ParseError> {
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<T extends { length: number }>(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<T extends { length: number }>(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<AdfNode> {
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<AdfNode[]>): Result<AdfNode> {
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<AdfNode[]> {
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
@@ -109,6 +109,7 @@ test('reads a folded callout to an expand titled by the rest of its marker line,
node('expand', { title: 'Why?' }, paragraph(text('See '), text('the docs', link), text(', '), text('now', strong), text('.'))),
])
assert.deepEqual(read('> [!NOTE]- Two\\\n> lines\n'), [node('expand', { title: 'Two' }, said('lines'))])
assert.deepEqual(read('> [!NOTE]- Set ==x== here\n'), normal(node('expand', { title: 'Set ==x== here' }, paragraph())))
assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))])
})
@@ -116,6 +117,7 @@ test('reads a folded callout inside an expand to a nested expand', () => {
const markdown = '> [!NOTE]- Outer\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n>\n> > [!TIP]\n> >\n> > > [!NOTE]-\n'
assert.deepEqual(read(markdown), normal(node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph())))))
assert.deepEqual(read('- > [!NOTE]-\n'), normal(bare('bulletList', bare('listItem', bare('expand', paragraph())))))
assert.deepEqual(read('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), normal(bare('expand', node('nestedExpand', { title: 'Inner' }, paragraph()))))
})
test('reads a bullet list whose every item leads with a task marker to a task list', () => {
@@ -202,6 +204,10 @@ test('keeps what markdownToAdf reads that no row reads, and refuses only what it
const lossless = markdownToAdf('> [!NOTE]\n\n- [x] ==a==\n')
assert.deepEqual(lossless.ok ? lossless.value.content : lossless.error.code, [bare('blockquote', said('[!NOTE]')), bare('bulletList', bare('listItem', said('[x] ==a==')))])
assert.equal(read('!adf:panel\n'), 'malformed-directive')
for (const markdown of ['> [!tip] ![a](u)\n', '> [!tip]\n> ![a](u)\n', '> [!NOTE]- t\n> ![a](u)\n', '> [!NOTE]- ![a](u)\n', '- [x] ![a](u)\n']) {
const parsed = plainMarkdownToAdf(markdown)
assert.deepEqual(parsed.ok ? parsed.value : [parsed.error.code, parsed.error.message], ['unmappable-image', 'an image fits only as a paragraph of its own: this one shares its paragraph with a marker'], markdown)
}
let deep = 'x\n'
for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}`
assert.equal(typeof read(deep), 'object')
-4
View File
@@ -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 = '=='