]{timestamp=1}\n')), 'unmappable-html')
assert.equal(code(losslessMarkdownToAdf('!adf:widget[
]\n')), 'unmappable-html')
diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts
index 70b9155..3bc9dac 100644
--- a/src/markdown/parse/markdown-to-adf.ts
+++ b/src/markdown/parse/markdown-to-adf.ts
@@ -20,14 +20,14 @@ import { readBlockDirectiveNode } from './directive-nodes.ts'
import { spellStringAttribute, unsupportedNodeShape } from '../directive-syntax.ts'
import { spellsEmpty } from '../empty-keys.ts'
-type Paragraph = Extract
+// A paragraph a marker opens: it shares its markdown paragraph with the marker, so no image there stands alone.
+type MarkedParagraph = { kind: 'markedParagraph'; position: SourcePosition; text: string }
+
+type ReadBlock = Block | MarkedParagraph
// `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 }
-const imageAfterMarker = 'an image fits only as a paragraph of its own: this one continues the paragraph a marker opens, which a blank line before it ends'
-const imageOnMarkerLine = 'an image fits only as a paragraph of its own: this one shares a line with a marker'
-
export function commonMarkToAdf(markdown: string): Result {
return readDocument(markdown, flavourClaims.commonmark)
}
@@ -64,7 +64,7 @@ function typeName(value: unknown): string {
return type === 'object' ? 'an object' : `a ${type}`
}
-function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result {
+function readBlocks(blocks: readonly ReadBlock[], reading: Reading, path: ConvertErrorPath, depth: number): Result {
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path)
const content: AdfNode[] = []
for (const [index, block] of blocks.entries()) {
@@ -86,7 +86,7 @@ function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErr
}
// spec/flavour.md, Directives: the separator builds no node, so only the pair it parts spells it.
-function listBreakFault(block: DirectiveBlock, previous: Block | undefined, next: Block | undefined): ConvertFault | undefined {
+function listBreakFault(block: DirectiveBlock, previous: ReadBlock | undefined, next: ReadBlock | undefined): ConvertFault | undefined {
if (block.argument !== undefined || block.attributes.size > 0) {
return unsupportedNodeShape(`${listBreakName} spells the bare leaf form, ${listBreakSpelling}: this one spells more`)
}
@@ -107,7 +107,7 @@ function partsFault(): ConvertFault {
return unsupportedNodeShape(`${listBreakName} parts two adjacent lists of one type: this one parts something else`)
}
-function readBlock(block: Block, reading: Reading, path: ConvertErrorPath, depth: number): Result {
+function readBlock(block: ReadBlock, reading: Reading, path: ConvertErrorPath, depth: number): Result {
switch (block.kind) {
case 'blockquote':
return reading.claims.alerts ? quoteNode(block.blocks, reading, path, depth) : containerNode({ type: 'blockquote' }, block.blocks, reading, path, depth)
@@ -123,6 +123,8 @@ function readBlock(block: Block, reading: Reading, path: ConvertErrorPath, depth
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 'markedParagraph':
+ 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':
@@ -147,8 +149,8 @@ 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 paragraphsOf(position: SourcePosition, ...texts: string[]): Paragraph[] {
- return texts.filter((text) => text !== '').map((text) => ({ kind: 'paragraph', position, text }))
+function paragraphsOf(position: SourcePosition, ...texts: string[]): MarkedParagraph[] {
+ return texts.filter((text) => text !== '').map((text) => ({ kind: 'markedParagraph', position, text }))
}
function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result {
@@ -157,13 +159,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' }, readMarked(paragraphsOf(led.position, line, rest), line !== '', body, reading, path, depth))
+ 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
- if (title.value.image !== undefined) return failure('unmappable-image', imageOnMarkerLine, path)
const text = titleText(title.value.nodes)
const type = reading.inExpand ? 'nestedExpand' : 'expand'
- return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readMarked(paragraphsOf(led.position, rest), false, body, { ...reading, inExpand: true }, path, depth))
+ 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.
@@ -192,15 +193,6 @@ function filledNode(node: AdfNode, content: Result): Result
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; `onMarkerLine` is whether the first one opens on the marker's line.
-function readMarked(marked: readonly Paragraph[], onMarkerLine: boolean, 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')
- if (image === -1) return read
- return failure('unmappable-image', image === 0 && onMarkerLine ? imageOnMarkerLine : imageAfterMarker, [...path, 'content', image])
-}
-
// A task list trailing an item's blocks stands beside it, as ADF nests one.
function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result {
const led = []
@@ -211,7 +203,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 = readMarked(paragraphsOf(position, text.replace(/^(?:[ \t\n]|\\\n)+/, '')), markerLine(text).line !== '', others, reading, [...path, 'content', index], depth)
+ const read = readBlocks([...paragraphsOf(position, text.replace(/^(?:[ \t\n]|\\\n)+/, '')), ...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
@@ -326,7 +318,7 @@ function codeBlockNode(language: string, text: string, reading: Reading, path: C
return success(text === '' ? node : { ...node, content: [{ text, type: 'text' }] })
}
-// spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs.
+// spec/flavour.md, The CommonMark image: only a plain paragraph gives a lone image the block it needs.
function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath): Result {
const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.claims)
if (!content.ok) return content
@@ -337,6 +329,5 @@ function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath):
function contentNode(node: AdfNode, text: string, reading: Reading, path: ConvertErrorPath, container: LineContainer): Result {
const content = parseInlineContent(text, reading.definitions, path, container, reading.claims)
if (!content.ok) return content
- if (content.value.image !== undefined) return failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path)
return success(withContent(node, content.value.nodes))
}
diff --git a/src/markdown/parse/portable-markdown-to-adf.test.ts b/src/markdown/parse/portable-markdown-to-adf.test.ts
index 355cb6d..ca5efdc 100644
--- a/src/markdown/parse/portable-markdown-to-adf.test.ts
+++ b/src/markdown/parse/portable-markdown-to-adf.test.ts
@@ -226,20 +226,17 @@ test('reads text the writer kept from reading as a marker back as text', () => {
assert.deepEqual(roundTripped(bare('taskList', task('DONE', text('[x] ==a==')))), [bare('taskList', task('DONE', text('[x] ==a==')))])
})
-test('reads what CommonMark reads where no row reads, a directive and a carry fence among it, and refuses only what it refuses', () => {
+test('reads what CommonMark reads where no row reads, a directive and a carry fence among it, an image sharing its paragraph with a marker among it', () => {
assert.deepEqual(read('!adf:panel\n'), [said('!adf:panel')])
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 refusal = (markdown: string): unknown => {
- const parsed = portableMarkdownToAdf(markdown)
- return parsed.ok ? parsed.value : [parsed.error.code, parsed.error.message]
- }
- for (const markdown of ['> [!tip] \n', '> [!NOTE]- \n', '- [x] \n']) {
- assert.deepEqual(refusal(markdown), ['unmappable-image', 'an image fits only as a paragraph of its own: this one shares a line with a marker'], markdown)
- }
- for (const markdown of ['> [!tip]\n> \n', '> [!tip] t\n> \n', '> [!NOTE]- t\n> \n', '- [x]\n \n']) {
- assert.deepEqual(refusal(markdown), ['unmappable-image', 'an image fits only as a paragraph of its own: this one continues the paragraph a marker opens, which a blank line before it ends'], markdown)
- }
+ const linked = paragraph(text('a', { attrs: { href: 'u' }, type: 'link' }))
+ assert.deepEqual(read('> [!tip] \n'), [panel('tip', linked)])
+ assert.deepEqual(read('> [!tip]\n> \n'), [panel('tip', linked)])
+ assert.deepEqual(read('> [!NOTE]- \n'), [node('expand', { title: 'a (u)' }, paragraph())])
+ assert.deepEqual(read('> [!NOTE]- t\n> \n'), [node('expand', { title: 't' }, linked)])
+ assert.deepEqual(read('- [x] \n'), [bare('taskList', task('DONE', ...(linked.content ?? [])))])
+ assert.deepEqual(read('> [!tip]\n>\n> \n'), [panel('tip', node('mediaSingle', { layout: 'center' }, { attrs: { alt: 'a', type: 'external', url: 'u' }, type: 'media' }))])
let deep = 'x\n'
for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}`
assert.equal(typeof read(deep), 'object')
diff --git a/src/result.ts b/src/result.ts
index 162c7e2..90166f7 100644
--- a/src/result.ts
+++ b/src/result.ts
@@ -5,7 +5,6 @@ export type ConvertErrorCode =
| 'not-an-adf-document'
| 'unknown-directive-name'
| 'unmappable-html'
- | 'unmappable-image'
| 'unspellable-whitespace'
| 'unsupported-document-version'
| 'unsupported-nesting-depth'
diff --git a/todo.md b/todo.md
index 681f883..feb4b30 100644
--- a/todo.md
+++ b/todo.md
@@ -6,7 +6,7 @@
`Bar = 9`
-`Next ID = 85`
+`Next ID = 87`
| Goal | W |
|---|---|
@@ -25,13 +25,13 @@
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|---|---|---|---|---|---|---|---|---|---|
-| 65 | 0.2.0 | defect | **Read an image not alone in its paragraph as its alt text linked to its URL, and a titled image alone in its paragraph as the image captioned with its title.** | 5 | 5 | 7 | 7 | 4, 5, 7 | 17.1 |
| 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 a link alone marks as `[](href)`, and one captioned with plain text as ``, in `adfToLosslessMarkdown`.** | 4 | 4 | 5 | 6 | 4, 6 | 13.0 |
+| 85 | 0.2.0 | defect | **Write an external image a link marks as `[](href)` in `adfToPortableMarkdown`, keeping the link target.** | 3 | 3 | 6 | 7 | 7 | 12.9 |
| 60 | 0.2.0 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 2 | 6 | 2 | 10.7 |
| 59 | 0.2.0 | decision | **Group the directive grammar into `src/markdown/directive/`, move `Read` to `result.ts`, and move `Flavour` and `flavourClaims` to `markdown/flavour.ts`.** | 3 | 5 | 2 | 6 | 2 | 10.4 |
| 62 | 0.2.0 | decision | **Move the portable flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/portable/`, so `emit/` no longer imports `portable/`.** | 4 | 5 | 2 | 6 | 2 | 9.4 |
-| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 4, 5, 7 | 9.0 |
| 7 | 0.3.0 | decision | **Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.** | 6 | 9 | 9 | 9 | 2, 4 | 25.8 |
| 45 | 0.3.0 | | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 3 | 6 | 8 | 1 | 25.2 |
| 6 | 0.3.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 4 | 24.7 |
@@ -65,19 +65,6 @@
## Details
-### 65. Read an image not alone in its paragraph as its alt text linked to its URL, and a titled image alone in its paragraph as the image captioned with its title.
-
-Both are refused today with `unmappable-image`: 14 examples in
-`corpus/commonmark-spec/refusals.json`, a bot's `See  here` among them. `mediaInline`
-takes only Atlassian media ids, never a URL. An image not alone in its paragraph reads as its alt
-text, linked to its URL. Its title becomes the link's `title`. An empty alt text reads as the URL,
-as item 50 reads an empty link. A titled image alone in its paragraph reads as the image, with its
-title as a `caption` (the maintainer, 2026-10-04). By Goal 5, every reader reads both as CommonMark
-does. An image inside a link: `[](/uri)` (spec example 517). Alone in its
-paragraph, it reads as the image with a `link` mark to `/uri`, which ADF's `media` takes. Not alone
-in its paragraph, the image reads as its alt text linked to `/uri`, and its URL drops. A writer
-panel chose this reading 3 of 3 (2026-10-04), by Goal 6, which outranks Goal 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
@@ -86,7 +73,7 @@ changing the marker, so `adfToLosslessMarkdown` and `adfToPortableMarkdown` alte
between adjacent bullet lists and `.` and `)` between adjacent ordered lists, and `!adf:listBreak`
retires. `*` is the maintainer's pick (2026-10-04). Breaking: `MIGRATION.md`'s Readings and
Spellings tables gain their rows. Examples 301 and 302 lose their `pending` exceptions, and the
-spelling leaves the README's "Four CommonMark spellings" bullet, which counts one fewer.
+spelling leaves the README's "Six CommonMark spellings" bullet, which counts one fewer.
### 61. Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.
@@ -96,6 +83,17 @@ only the property tests catch drift. It caps the panel's Locality score. The rep
time linear in the line: today `mergesWithSyntax`, `touchesSyntax` and `closesHeading` rescan a run
of one character from each of its characters.
+### 86. Write an image a link alone marks as `[](href)`, and one captioned with plain text as ``, in `adfToLosslessMarkdown`.
+
+Every reader reads both spellings so; `adfToLosslessMarkdown` writes the `!adf:mediaSingle`
+directive for them. The emitted form is API (`docs/decisions.md` §The formats are API), so it
+changes before `0.2.0` ships. A writer panel settles the spelling.
+
+### 85. Write an external image a link marks as `[](href)` in `adfToPortableMarkdown`, keeping the link target.
+
+Today `[](/v)` reads as the image whose `media` the link marks, and `adfToPortableMarkdown`
+writes ``, dropping `/v`: the README promises it keeps link targets (Goal 7).
+
### 60. Collect the questions `parse/` asks `emit/` into one named module.
`parse/` asks `emit/` through `commonMarkSpelling` and `openingLinkTakesDirective`, each imported
@@ -119,14 +117,6 @@ Writing: `spellPortableBlock`, `quotedUnder`, `tryTaskList` and `taskBlocks` in
`flavourClaims`. Every comprehension reader on 2026-10-03 named the portable flavour's spread across
three directories.
-### 50. Read `[](/url)` and `[]()` as CommonMark's empty link.
-
-Both stay literal text today (spec examples 484 and 487). ADF holds no empty text node to carry a
-link mark, so a link whose text is empty takes its URL as its text (the maintainer, 2026-10-04):
-`[](/url)` reads as `/url` linked to `/url`. `[]()` has no URL to show; the chunk settles it by
-Goals 4 and 7. Breaking: `MIGRATION.md`'s Readings table gains its row. Its `pending` exceptions go,
-and its spelling leaves the README's "Four CommonMark spellings" bullet, which counts one fewer.
-
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.
Lands after items 6, 59, 60, 61 and 62. Which flavours get a composed call is this item's to settle;
@@ -250,7 +240,7 @@ A writer panel chose it on 2026-10-03, 5 of 7, over today's `colwidth="[340,420]
`link-syntax.ts` normalizes a label with `toLowerCase`, so `[ẞ]` misses its `[SS]` definition (spec
example 540); lowercasing and then uppercasing folds it. Breaking: `MIGRATION.md`'s Readings table
-gains its row. Its `pending` exceptions go, and its spelling leaves the README's "Four CommonMark
+gains its row. Its `pending` exceptions go, and its spelling leaves the README's "Six CommonMark
spellings" bullet, which counts one fewer.
### 53. Put a block's `marks` spelling to the writer panel and adopt its pick.