From 88aef1c8c4e8cdb96b68e4e4ff1d2f0400535760 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 9 Oct 2026 03:37:29 +0200 Subject: [PATCH] Answer architecture round 1: one owner for the image's ADF shape, the readings' decision, marker paragraphs, the exceptions the README names; items 87 filed and 56 widened --- AGENTS.md | 1 + CHANGELOG.md | 11 ++++---- MIGRATION.md | 2 +- README.md | 14 ++++++---- docs/decisions.md | 13 +++++++++ spec/flavour.md | 5 ++-- src/conformance/commonmark-spec.test.ts | 5 ++-- src/markdown/emit/image.ts | 28 ++++++------------- src/markdown/external-image.ts | 10 +++++++ src/markdown/parse/inline-content.ts | 6 ++-- src/markdown/parse/markdown-to-adf.test.ts | 4 ++- src/markdown/parse/markdown-to-adf.ts | 16 +++++------ .../portable/adf-to-portable-markdown.ts | 5 ++-- todo.md | 13 ++++++++- 14 files changed, 81 insertions(+), 52 deletions(-) create mode 100644 src/markdown/external-image.ts diff --git a/AGENTS.md b/AGENTS.md index 3eae90e..1a2a24e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ In `docs/decisions.md`: - CommonMark is a subset - Tables - Links +- What ADF holds no node for reads as a link - Ids stay site-local - Portable task ids come from position - A callout title keeps its link targets diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e2787a..de948e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,11 +57,12 @@ `collection`, `id` and `occurrenceKey` directly where they rode the opaque carry. - Fix an image inside another image's description: it flattens into the alt text, where it was refused. -- **Breaking:** `unmappable-image` leaves `ConvertErrorCode`, and every image reads. One not alone - in its paragraph reads as its alt text linked to its URL, a titled one alone in its paragraph as - the image captioned with its title, and one that is a link's whole text as the image the link - marks. An empty link text reads as its destination, `[](/url)` as `/url` linked, and `[]()` as - nothing, where both stayed literal text. See `MIGRATION.md`. +- **Breaking:** `unmappable-image` leaves `ConvertErrorCode`, and every image reads. One anywhere + but alone in a plain paragraph reads as its alt text linked to its URL, or inside a link as plain + text the link marks. A titled one alone in a plain paragraph reads as the image captioned with + its title, and a link alone in a plain paragraph whose whole text is an image as the image the + link marks. An empty link text reads as its destination, `[](/url)` as `/url` linked, and + `[]()` as nothing, where both stayed literal text. See `MIGRATION.md`. - Fix a JSON key holding an escape, in a carried node or a JSON attribute such as `parameters`: `losslessMarkdownToAdf` reads it as the key it spells, where on V8 13.6 and later (Node 24, Deno, Chrome) `markdownToAdf` could refuse markdown `adfToMarkdown` wrote, as `unsupported-node-shape`, diff --git a/MIGRATION.md b/MIGRATION.md index 90a6207..23f2a5b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -79,7 +79,7 @@ four now reads, or refuses with the code the table gives. | Input | `0.1.0` | `0.2.0` | | --- | --- | --- | -| an image not alone in its paragraph (`See ![a](u).`, `# ![a](u)`), on parse | `unmappable-image` | its alt text linked to its URL, or, beside other text inside a link, plain text the link marks | +| an image anywhere but alone in a plain paragraph (`See ![a](u).`, `# ![a](u)`), on parse | `unmappable-image` | its alt text linked to its URL, or, inside a link, plain text the link marks | | a titled image alone in its paragraph (`![a](u "t")`), on parse | `unmappable-image` | the image, captioned with its title | | an image that is a link's whole text, alone in its paragraph (`[![a](u)](/v)`), on parse | `unmappable-image` | the image, its `media` marked with the link | | a link whose `href` or `title` no CommonMark escape spells, on emit | `unspellable-link` | spells `!adf:link[text]{attrs}` | diff --git a/README.md b/README.md index b65eef3..3a36c28 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ products store as ADF. started from — panels, mentions, statuses and node types this version does not know included. - **Markdown in:** `portableMarkdownToAdf` reads a bot's or a model's markdown, including `~~` and pipe tables whose every row opens with a pipe. `commonMarkToAdf` reads strict CommonMark as its - spec says: nothing becomes a table, task or panel. Both refuse raw HTML and the exceptions - [The guarantees](#the-guarantees) lists. + spec says: nothing becomes a table, task or panel. Both refuse raw HTML, and + `portableMarkdownToAdf` a claimed shape that does not complete + ([The guarantees](#the-guarantees)). - **Portable markdown, lossy:** `adfToPortableMarkdown` and `portableMarkdownToAdf` convert to and from the markdown that GitHub, GitLab and Obsidian render — GitHub Flavored Markdown's alerts, task lists and pipe tables, Obsidian Flavored Markdown's callouts and highlights. @@ -241,10 +242,11 @@ Serves Goals 1, 4 and 5. implementation renders differently. Three because ADF holds no node for what it renders: - An empty link text reads as its destination, `[](/url)` as `/url` linked to `/url`, and `[]()` as nothing. - - An image not alone in its paragraph reads as its alt text linked to its URL, `See ![the - moon](moon.png).` as `See [the moon](moon.png).`; beside other text inside a link, as plain - text the link marks. - - An image's title reads as its caption. + - An image anywhere but alone in a plain paragraph reads as its alt text linked to its URL, + `See ![the moon](moon.png).` as `See [the moon](moon.png).`, and inside a link as plain text + the link marks. + - An image's title reads as its caption where the image stands alone in a plain paragraph, and + as its link's title where it reads as linked alt text. And three more: a list continuing past a marker change stays one list against CommonMark's two, a shortcut reference matching its definition only under Unicode case folding stays unresolved, diff --git a/docs/decisions.md b/docs/decisions.md index 7149f02..813438e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -202,6 +202,19 @@ link reference definition — and a directive link CommonMark could spell is ref link — the bracket form goes literal, the directive form refused — which is CommonMark's prose where its reference implementation nests one `` in another. +## What ADF holds no node for reads as a link + +2026-10-04, the maintainer and a writer panel, 3 of 3; `[]()` 2026-10-09, by Goals 4 and 7. Goals 4, +5 and 6. Valid while `mediaInline` carries no URL and ADF holds no empty text node. + +Every reader reads every CommonMark image and empty link. An image anywhere but alone in a plain +paragraph reads as its alt text linked to its URL, titled by its title; an empty alt text reads as +the URL, and an empty link text as its destination. `[]()` reads as nothing: it renders nothing, so +Goal 7 loses no content. A titled image alone in a plain paragraph reads as the image captioned with +its title. A link alone in a plain paragraph whose whole text is an image reads as the image with +the link marking its `media`. Inside a link otherwise, the alt text is plain text the link marks and +the image's URL drops: the panel chose this by Goal 6, which outranks Goal 7. + ## 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 0cbf9b4..9569fbf 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -323,8 +323,9 @@ directive form instead. Anywhere else — beside other content, in a heading, a table cell or an `!adf:paragraph` — an image reads as its alt text linked to its destination, the link titled by the image's title, since `mediaInline` carries a media `collection` + `id`, never a URL. An empty alt text reads as the -destination, as an empty link text does. Inside a link's text, beside other content, the alt text -is plain text the link marks, and the image's destination drops. +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` §What ADF holds no node for +reads as a link). ### Tables diff --git a/src/conformance/commonmark-spec.test.ts b/src/conformance/commonmark-spec.test.ts index 974df20..1d701fc 100644 --- a/src/conformance/commonmark-spec.test.ts +++ b/src/conformance/commonmark-spec.test.ts @@ -11,9 +11,10 @@ import { commonMarkToAdf, losslessMarkdownToAdf } from '../markdown/parse/markdo const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus', 'commonmark-spec') const checks = ['count', 'fixpoint', 'text'] as const +const kinds = ['mark-model', 'node-model', 'pending'] as const type Check = (typeof checks)[number] -type ExceptionKind = 'mark-model' | 'node-model' | 'pending' +type ExceptionKind = (typeof kinds)[number] type SpecExample = { example: number; html: string; markdown: string; section: string } @@ -30,7 +31,7 @@ function isCheck(value: unknown): value is Check { } function isKind(value: unknown): value is ExceptionKind { - return value === 'mark-model' || value === 'node-model' || value === 'pending' + return kinds.some((kind) => kind === value) } function isSpecExample(value: unknown): value is SpecExample { diff --git a/src/markdown/emit/image.ts b/src/markdown/emit/image.ts index c1e8f2f..6058bac 100644 --- a/src/markdown/emit/image.ts +++ b/src/markdown/emit/image.ts @@ -1,26 +1,14 @@ import type { AdfNode } from '../../adf/document.ts' -import { holdsOnlyAttributes, nodeAttrs, nodeContent } from '../../adf/document.ts' +import { centeredImage, externalMedia } from '../external-image.ts' +import { nodeAttrs, nodeContent } from '../../adf/document.ts' import { serializeCanonicalJson } from '../../canonical-json.ts' import { tryImageLine } from './inline-line.ts' -const centeredMediaSingle = '{"layout":"center"}' -const imageAttributes = ['alt', 'type', 'url'] - +// An empty `alt` takes the directive form: `![](url)` reads back with no `alt`. export function tryImage(node: AdfNode): string | undefined { - const image = imageShape(node) - if (image === undefined) return undefined - return tryImageLine(image.alt, image.url) -} - -function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined { - const content = nodeContent(node) - const media = content[0] - if (!holdsOnlyAttributes(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) return undefined - if (media === undefined || content.length !== 1 || media.type !== 'media' || !holdsOnlyAttributes(media, imageAttributes) || nodeContent(media).length > 0) return undefined - const attrs = nodeAttrs(media) - const alt = attrs['alt'] - const url = attrs['url'] - if (attrs['type'] !== 'external' || typeof url !== 'string') return undefined - if (alt !== undefined && (typeof alt !== 'string' || alt === '')) return undefined - return { alt, url } + const [media] = nodeContent(node) + const { alt, url } = media === undefined ? {} : nodeAttrs(media) + if (typeof url !== 'string' || (alt !== undefined && typeof alt !== 'string') || alt === '') return undefined + const read = centeredImage([externalMedia(alt ?? '', url)]) + return serializeCanonicalJson(node, 'compact') === serializeCanonicalJson(read, 'compact') ? tryImageLine(alt, url) : undefined } diff --git a/src/markdown/external-image.ts b/src/markdown/external-image.ts new file mode 100644 index 0000000..921c324 --- /dev/null +++ b/src/markdown/external-image.ts @@ -0,0 +1,10 @@ +import type { AdfNode } from '../adf/document.ts' + +// spec/flavour.md, The CommonMark image: the shape `![alt](url)` reads to, and the only one written back as it. +export function centeredImage(content: AdfNode[]): AdfNode { + return { attrs: { layout: 'center' }, content, type: 'mediaSingle' } +} + +export function externalMedia(alt: string, url: string): AdfNode { + return { attrs: alt === '' ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' } +} diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index 349a56e..2265ed7 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -6,6 +6,7 @@ import type { LineContainer } from '../line-container.ts' import type { LinkDefinition } from '../commonmark/link-syntax.ts' import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark/grammar.ts' import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts' +import { centeredImage, externalMedia } from '../external-image.ts' import { commonMarkLink, linkHref } from '../mark-spellings.ts' import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' @@ -309,11 +310,10 @@ function linkedAlt(piece: Piece): Piece { } function mediaSingle({ alt, definition, link }: Image): AdfNode { - const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination } - const media: AdfNode = link === undefined ? { attrs, type: 'media' } : { attrs, marks: [linkMark(link)], type: 'media' } + const media = externalMedia(alt, definition.destination) const { title } = definition const caption: AdfNode[] = title === undefined || title === '' ? [] : [{ content: [{ text: title, type: 'text' }], type: 'caption' }] - return { attrs: { layout: 'center' }, content: [media, ...caption], type: 'mediaSingle' } + return centeredImage([link === undefined ? media : { ...media, marks: [linkMark(link)] }, ...caption]) } function linkMark({ destination, title }: LinkDefinition): AdfMark { diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 65a7502..2fda780 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -1027,9 +1027,11 @@ test('reads an image no paragraph holds alone as its alt text, linked to its des assert.deepEqual(firstContent('# ![a](/u)\n'), [marked('a', link('/u'))]) assert.deepEqual(firstContent('!adf:paragraph {localId=p}\n![a](/u)\n!adf:/paragraph\n'), [marked('a', link('/u'))]) assert.deepEqual(firstContent('![a ![b](/c) d\n'), [text('![a '), marked('b', link('/c')), text(' d')]) + assert.deepEqual(content(losslessMarkdownToAdf('![a]\n')), [paragraph('![a]')]) + assert.deepEqual(content(losslessMarkdownToAdf('a ! b\n')), [paragraph('a ! b')]) }) -test('reads an image beside other content in a link as its alt text, the link\'s own text', () => { +test('reads an image inside a link not alone in its paragraph as plain alt text the link marks, dropping its own destination', () => { assert.deepEqual(firstContent('See [![a](/u "t")](/v).\n'), [text('See '), marked('a', link('/v')), text('.')]) assert.deepEqual(firstContent('See [![](/u)](/v).\n'), [text('See '), marked('/v', link('/v')), text('.')]) assert.deepEqual(firstContent('[b ![a](/u)](/v)\n'), [marked('b a', link('/v'))]) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 3bc9dac..c522e26 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -21,9 +21,9 @@ import { spellStringAttribute, unsupportedNodeShape } from '../directive-syntax. 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 MarkedParagraph = { kind: 'markedParagraph'; position: SourcePosition; text: string } +type MarkerParagraph = { kind: 'markerParagraph'; position: SourcePosition; text: string } -type ReadBlock = Block | MarkedParagraph +type ReadBlock = Block | MarkerParagraph // `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,7 +123,7 @@ 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 'markedParagraph': + 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) @@ -149,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[]): MarkedParagraph[] { - return texts.filter((text) => text !== '').map((text) => ({ kind: 'markedParagraph', position, text })) +function markerParagraphs(position: SourcePosition, ...texts: string[]): MarkerParagraph[] { + return texts.filter((text) => text !== '').map((text) => ({ kind: 'markerParagraph', position, text })) } function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result { @@ -159,12 +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' }, readBlocks([...paragraphsOf(led.position, line, rest), ...body], reading, path, depth + 1)) + if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readBlocks([...markerParagraphs(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([...paragraphsOf(led.position, rest), ...body], { ...reading, inExpand: true }, path, depth + 1)) + return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readBlocks([...markerParagraphs(led.position, rest), ...body], { ...reading, inExpand: true }, path, depth + 1)) } // docs/decisions.md, A callout title keeps its link targets. @@ -203,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 = readBlocks([...paragraphsOf(position, text.replace(/^(?:[ \t\n]|\\\n)+/, '')), ...others], reading, [...path, 'content', index], depth + 1) + const read = readBlocks([...markerParagraphs(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 diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index 438f790..8a1a228 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -1,6 +1,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts' import { blockNodeModel } from '../../adf/block-nodes.ts' +import { centeredImage, externalMedia } from '../external-image.ts' import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { flavourClaims, taskMarker } from './conventions.ts' @@ -405,7 +406,5 @@ function reduceMedia(media: AdfNode, reduction: Reduction): Result { if (attrs['type'] !== 'external' || typeof url !== 'string') return paragraphOfNode(media, reduction) const held = attrs['alt'] const alt = typeof held === 'string' ? oneLine(held).trim() : '' - const external: AdfNode = { attrs: alt === '' ? { type: 'external', url: writableHref(url) } : { alt, type: 'external', url: writableHref(url) }, type: 'media' } - const image: AdfNode = { attrs: { layout: 'center' }, content: [external], type: 'mediaSingle' } - return success([image]) + return success([centeredImage([externalMedia(alt, writableHref(url))])]) } diff --git a/todo.md b/todo.md index feb4b30..dcf479d 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 87` +`Next ID = 88` | Goal | W | |---|---| @@ -25,6 +25,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 | | 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 `[![alt](url)](href)`, and one captioned with plain text as `![alt](url "caption")`, in `adfToLosslessMarkdown`.** | 4 | 4 | 5 | 6 | 4, 6 | 13.0 | @@ -65,6 +66,13 @@ ## Details +### 87. Build a `hardBreak` carrying no marks where emphasis or a link spans a line break, in every reader. + +`*foo \nbar*` builds a `hardBreak` marked `em` (CommonMark examples 638 and 639), which Atlassian's +schema refuses: its `hardBreak_node` holds no `marks` (`spec/adf-schema/full.json`). So +`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`. + ### 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 @@ -316,6 +324,9 @@ functions write `pending`, `pieces`, `deactivatedBefore` and `openingSpellableLi `takeFallback` (`emit/inline-line.ts`), which writes the record the next `lineSegments` pass reads; `writeUnpaired`, `markPairings` and `markHighlights` (`inline-content.ts`), whose order changes the output; and `nestIn` (`portable/adf-to-portable-markdown.ts`), which pushes into its caller's array. +Once `Scan`'s pieces have owners, the bracket resolution — `closeBracket` through `closeImage`, +`linkPiece`, `linkTo`, `linkedAlt`, `mediaSingle`, `linkMark`, `holdsLink`, `imageAlt` and +`altText` — can leave `inline-content.ts`, about 690 lines over five concerns, for a module of its own. ### 76. State the files the package does not ship once, so the build, the lint ceiling and the coverage exclusions cannot drift apart.