diff --git a/README.md b/README.md index efbecfd..b539568 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,7 @@ Serves Goals 1, 4 and 5. - An empty link text reads as its destination, `[](/url)` as `/url` linked to `/url`, and `[]()` as nothing. - An image reads as an image only alone in its paragraph. Anywhere else — beside other text, in - a heading or a table cell, or in the paragraph an alert or task marker opens — it reads as its + a heading or a table cell, or in the paragraph a task marker opens — it reads as its alt text linked to its URL: `See ![the moon](moon.png).` reads as `See [the moon](moon.png).`. To keep an image an image, give it a paragraph of its own: a blank line between it and any other line. diff --git a/docs/decisions.md b/docs/decisions.md index 1846167..ff87eeb 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -217,6 +217,12 @@ reads as that image, with the link marking its `media`. Inside any other link, t text the link marks, and the image's URL and title drop: the panel chose this by Goal 6, which outranks Goal 7. +In `portableMarkdownToAdf`, an alert's or callout's marker line stands apart from the lines after +it, so an image alone on either reads as the image: a reader panel of the bot, the LLM pipeline and +a support agent, 3 of 3 on 2026-10-09, expected the picture in every case, a ticked task included. +A task item holds no image in Atlassian's schema, so the paragraph a task marker opens reads its +image as linked alt text, the fallback two of the three named. + ## Ids stay site-local 2026-08-23, the maintainer. Goal 1. Valid while ADF ids are minted per site. diff --git a/spec/flavour.md b/spec/flavour.md index 5ebb2af..a7c372d 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -322,8 +322,9 @@ directive form instead. `mediaInline` carries a media `collection` + `id`, never a URL, so anywhere else an image reads as its alt text linked to its destination, the link titled by the image's title: beside other -content, in a heading, a table cell or an `!adf:paragraph`, and in the paragraph a portable alert, -callout or task marker opens. An empty alt text reads as the destination, as an empty link text +content, in a heading, a table cell or an `!adf:paragraph`, and in the paragraph a portable task +marker opens. An alert's or callout's marker line stands apart from the lines after it, so an image +alone on either reads as the image. An empty alt text reads as the destination, as an empty link text does. Inside a link's text, the alt text is plain text the link marks, and the image's destination and title drop (`docs/decisions.md` §An image reads as an image only alone in its paragraph). diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index ff6a73e..91815fb 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -20,10 +20,12 @@ import { readBlockDirectiveNode } from './directive-nodes.ts' import { spellStringAttribute, unsupportedNodeShape } from '../directive-syntax.ts' import { spellsEmpty } from '../empty-keys.ts' -// A paragraph a marker opens: it shares its markdown paragraph with the marker, so no image there stands alone. -type MarkerParagraph = { kind: 'markerParagraph'; position: SourcePosition; text: string } +type Paragraph = Extract -type ReadBlock = Block | MarkerParagraph +// 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 } + +type ReadBlock = Block | TaskParagraph // `inExpand` is whether an expand holds the blocks, which makes a folded callout a nestedExpand. type Reading = { claims: Claims; definitions: LinkDefinitions; inExpand: boolean; memo: SpellingMemo } @@ -123,8 +125,6 @@ function readBlock(block: ReadBlock, reading: Reading, path: ConvertErrorPath, d return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, reading, path, 'heading') case 'html': return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path) - case 'markerParagraph': - return contentNode({ type: 'paragraph' }, block.text, reading, path, 'paragraph') case 'orderedList': return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, reading, path, depth) case 'paragraph': @@ -133,6 +133,8 @@ function readBlock(block: ReadBlock, reading: Reading, path: ConvertErrorPath, d return success({ type: 'rule' }) case 'table': return tableNode(block.rows, reading, path) + case 'taskParagraph': + return contentNode({ type: 'paragraph' }, block.text, reading, path, 'paragraph') } } @@ -149,8 +151,9 @@ function markerLine(text: string): { line: string; rest: string } { return { line: (hardBreak ? line.slice(0, -1) : line).replace(/^[ \t]+/, ''), rest: lineEnd === -1 ? '' : text.slice(lineEnd + 1) } } -function markerParagraphs(position: SourcePosition, ...texts: string[]): MarkerParagraph[] { - return texts.filter((text) => text !== '').map((text) => ({ kind: 'markerParagraph', position, text })) +// docs/decisions.md, An image reads as an image only alone in its paragraph: an alert's marker line stands apart from the lines after it. +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 { @@ -159,12 +162,12 @@ 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' }, readBlocks([...markerParagraphs(led.position, line, rest), ...body], reading, path, depth + 1)) + if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readBlocks([...paragraphsOf(led.position, line, rest), ...body], reading, path, depth + 1)) const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.claims) 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([...markerParagraphs(led.position, rest), ...body], { ...reading, inExpand: true }, path, depth + 1)) + return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readBlocks([...paragraphsOf(led.position, rest), ...body], { ...reading, inExpand: true }, path, depth + 1)) } // docs/decisions.md, A callout title keeps its link targets. @@ -203,7 +206,9 @@ 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([...markerParagraphs(position, text.replace(/^(?:[ \t\n]|\\\n)+/, '')), ...others], reading, [...path, 'content', index], depth + 1) + const body = text.replace(/^(?:[ \t\n]|\\\n)+/, '') + const marked: TaskParagraph[] = body === '' ? [] : [{ kind: 'taskParagraph', position, text: body }] + const read = readBlocks([...marked, ...others], reading, [...path, 'content', index], depth + 1) if (!read.ok) return read let beside = read.value.length while (read.value[beside - 1]?.type === 'taskList') beside -= 1 diff --git a/src/markdown/parse/portable-markdown-to-adf.test.ts b/src/markdown/parse/portable-markdown-to-adf.test.ts index ca5efdc..644e39e 100644 --- a/src/markdown/parse/portable-markdown-to-adf.test.ts +++ b/src/markdown/parse/portable-markdown-to-adf.test.ts @@ -231,12 +231,15 @@ test('reads what CommonMark reads where no row reads, a directive and a carry fe assert.deepEqual(read('a !adf:carry{json="{}"} \\!adf:x\n'), [said('a !adf:carry{json="{}"} !adf:x')]) assert.deepEqual(read('```adf:blockCard\n{}\n```\n'), [node('codeBlock', { language: 'adf:blockCard' }, text('{}'))]) const linked = paragraph(text('a', { attrs: { href: 'u' }, type: 'link' })) - assert.deepEqual(read('> [!tip] ![a](u)\n'), [panel('tip', linked)]) - assert.deepEqual(read('> [!tip]\n> ![a](u)\n'), [panel('tip', linked)]) + const image = node('mediaSingle', { layout: 'center' }, { attrs: { alt: 'a', type: 'external', url: 'u' }, type: 'media' }) + assert.deepEqual(read('> [!tip] ![a](u)\n'), [panel('tip', image)]) + assert.deepEqual(read('> [!tip]\n> ![a](u)\n'), [panel('tip', image)]) + assert.deepEqual(read('> [!tip]\n>\n> ![a](u)\n'), [panel('tip', image)]) + assert.deepEqual(read('> [!tip]\n> ![a](u) b\n'), [panel('tip', paragraph(...(linked.content ?? []), text(' b')))]) + assert.deepEqual(read('> [!NOTE]- t\n> ![a](u)\n'), [node('expand', { title: 't' }, image)]) assert.deepEqual(read('> [!NOTE]- ![a](u)\n'), [node('expand', { title: 'a (u)' }, paragraph())]) - assert.deepEqual(read('> [!NOTE]- t\n> ![a](u)\n'), [node('expand', { title: 't' }, linked)]) assert.deepEqual(read('- [x] ![a](u)\n'), [bare('taskList', task('DONE', ...(linked.content ?? [])))]) - assert.deepEqual(read('> [!tip]\n>\n> ![a](u)\n'), [panel('tip', node('mediaSingle', { layout: 'center' }, { attrs: { alt: 'a', type: 'external', url: 'u' }, type: 'media' }))]) + assert.deepEqual(read('- [x]\n ![a](u)\n'), [bare('taskList', task('DONE', ...(linked.content ?? [])))]) let deep = 'x\n' for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}` assert.equal(typeof read(deep), 'object') diff --git a/todo.md b/todo.md index 3c57862..c302574 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 88` +`Next ID = 89` | Goal | W | |---|---| @@ -26,6 +26,7 @@ | ID | Release | Exempt | Item | R | S | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---| | 87 | 0.2.0 | defect | **Build a `hardBreak` carrying no marks where emphasis or a link spans a line break, in every reader.** | 4 | 3 | 6 | 7 | 4 | 16.6 | +| 88 | 0.2.0 | defect | **Build a `blockTaskItem` holding only the paragraphs and extensions Atlassian's schema allows, in `portableMarkdownToAdf`.** | 4 | 4 | 6 | 7 | 4 | 16.4 | | 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists, in every reader, and retire `!adf:listBreak`.** | 4 | 5 | 5 | 8 | 4, 5 | 15.5 | | 61 | 0.2.0 | decision | **Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.** | 6 | 8 | 3 | 8 | 1 | 14.0 | | 86 | 0.2.0 | | **Write an image whose `media` only a link marks, and one captioned with plain text, in the CommonMark spelling a writer panel picks, in `adfToLosslessMarkdown`.** | 4 | 4 | 5 | 6 | 4, 6 | 13.0 | @@ -73,6 +74,13 @@ schema refuses: its `hardBreak_node` holds no `marks` (`spec/adf-schema/full.jso `commonMarkToAdf` builds a document a site may reject from plain CommonMark. The suite's `em 1/3` exceptions for 638 and 639 go back to `em 1/2`. +### 88. Build a `blockTaskItem` holding only the paragraphs and extensions Atlassian's schema allows, in `portableMarkdownToAdf`. + +`- [x] done\n\n ![a](u)` builds a `blockTaskItem` holding a `mediaSingle`, and `- [x] done\n\n > q` +one holding a `blockquote`; the schema's `blockTaskItem_node` takes paragraphs and extensions only +(`spec/adf-schema/full.json`), so a site may reject the document. The reader needs a reading for +the other blocks, settled by Goals 4 and 7. + ### 49. Read a list whose bullet or ordered delimiter changes as two lists, in every reader, and retire `!adf:listBreak`. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads two (spec examples