From 5f2c5083e91cdac738f53b3d76f15b617ae00da4 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 11 Oct 2026 12:27:55 +0200 Subject: [PATCH] Answer architecture round 1: the portable writer builds the linked image through externalMedia, the properties generate the image shapes, linkDefinition sits beside linkMark, the changelog drops a refusal no release met, and items 99 and 100 file the inline linked image and the caption reading --- CHANGELOG.md | 3 +-- MIGRATION.md | 2 +- src/conformance/property-harness.ts | 7 +++++++ src/markdown/emit/adf-to-markdown.test.ts | 1 + src/markdown/emit/image.ts | 8 +------- src/markdown/emit/inline-line.ts | 13 ++++++++----- src/markdown/mark-spellings.ts | 10 +++++++++- .../portable/adf-to-portable-markdown.ts | 4 ++-- todo.md | 17 ++++++++++++++++- 9 files changed, 46 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ced11..39a59d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,8 +60,7 @@ list's own indentation, ends the list, as CommonMark reads it; `0.1.0` read one list. See `MIGRATION.md`. - **Breaking:** `adfToLosslessMarkdown` writes an external image only a link marks as - `[![alt](url)](href)`, and `losslessMarkdownToAdf` refuses its directive form as - `unsupported-node-shape`. Convert stored markdown per `MIGRATION.md`. + `[![alt](url)](href)`. Convert stored markdown per `MIGRATION.md`. - Fix the time `adfToLosslessMarkdown` and `adfToPortableMarkdown` take on text holding a long run of `*`, `_`, `~` or backticks, or a heading ending in a long run of `#`: the time is linear in the run, and a run of 20000 `*` takes milliseconds where it took seconds. diff --git a/MIGRATION.md b/MIGRATION.md index 4239df8..a5ff8f6 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -60,7 +60,7 @@ function migrateMarkdown(stored: string) { | --- | --- | | `:::panel info` … `:::`, the fence longer per nesting level | `!adf:panel info` … `!adf:/panel` at any depth | | `::media {id=a type=file}` | `!adf:media {id=a type=file}` | -| a `mediaSingle` directive holding one external `media` a link marks | `[![alt](url)](href)` | +| `:::mediaSingle {layout=center}`, `::media {alt=a marks="[{\"attrs\":{\"href\":\"/v\"},\"type\":\"link\"}]" type=external url=u}`, `:::` | `[![a](u)](/v)` | | `::taskItem TODO {localId=i}`: an empty `caption`, `decisionItem`, `paragraph` or `taskItem`, or an empty `heading` carrying `localId` | `!adf:taskItem TODO {localId=i}` then `!adf:/taskItem` | | `:mention[@Mikael]{id=5b10a2}` | `!adf:mention[@Mikael]{id=5b10a2}` | | the `adf` code fence and `:adf{json="…"}` | the `adf:` code fence, its JSON without `type`, and `!adf:carry{json="…"}` | diff --git a/src/conformance/property-harness.ts b/src/conformance/property-harness.ts index 330b80c..2b6375e 100644 --- a/src/conformance/property-harness.ts +++ b/src/conformance/property-harness.ts @@ -6,8 +6,10 @@ import type { AdfAttributes, AdfDocument, AdfMark, AdfNode, EmptyKey } from '../ import type { Arbitrary } from 'fast-check' import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { JsonValue } from '../json-value.ts' +import type { LinkDefinition } from '../markdown/commonmark/link-syntax.ts' import { blockArgument } from '../markdown/block-directive.ts' import { blockNodes } from '../adf/block-nodes.ts' +import { centeredImage, externalMedia } from '../markdown/external-image.ts' import { directivePrefix } from '../markdown/directive-syntax.ts' import { emptyKeys, mergeAdjacentText, nodeContent } from '../adf/document.ts' import { inlineNodes } from '../adf/inline-nodes.ts' @@ -91,6 +93,10 @@ function occasionallyApart(arbitrary: Arbitrary): Arbitrary (roll === 0 ? nodes : mergeAdjacentText(nodes, joinsWhenRead))) } +function externalImage({ alt, link, url }: { alt: string | undefined; link: LinkDefinition | undefined; url: string }): AdfNode { + return centeredImage([externalMedia(alt ?? '', url, link)]) +} + function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode { const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))] return { content: rows.map((content): AdfNode => ({ content, type: 'tableRow' })), type: 'table' } @@ -155,6 +161,7 @@ const positions = fc.letrec((tie) => { occasionallyEmpty(fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' }))), paragraph, fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable), + fc.record({ alt: fc.option(text, { nil: undefined }), link: fc.option(fc.record({ destination: text, title: text }, { requiredKeys: ['destination'] }), { nil: undefined }), url: text }).map(externalImage), ] const task = (type: string, content: Arbitrary) => occasionallyEmpty(fc.record({ content, state: fc.constantFrom('DONE', 'TODO') }).map(({ content: held, state }): AdfNode => ({ attrs: { state }, content: held, type }))) diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 1eb4465..05198af 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -644,6 +644,7 @@ test('spells a media only a link marks as the image the link wraps', () => { assert.ok(fallback(linked('m', link({ href: '/d' }), { attrs: { color: '#000', size: 1 }, type: 'border' }))) assert.ok(fallback(linked('m', link({ href: 'a\\b' })))) assert.ok(fallback(linked('m', link({ title: 't' })))) + assert.ok(fallback(linked('m', link({ href: '/d', title: 4 })))) const captioned: AdfNode = { attrs: { layout: 'center' }, content: [{ attrs: { alt: 'm', type: 'external', url }, type: 'media' }, { content: [{ text: 'c', type: 'text' }], type: 'caption' }], type: 'mediaSingle' } assert.ok(fallback(document(captioned))) }) diff --git a/src/markdown/emit/image.ts b/src/markdown/emit/image.ts index ec8ed96..1b1558c 100644 --- a/src/markdown/emit/image.ts +++ b/src/markdown/emit/image.ts @@ -1,6 +1,6 @@ import type { AdfNode } from '../../adf/document.ts' -import type { LinkDefinition } from '../commonmark/link-syntax.ts' import { centeredImage, externalMedia } from '../external-image.ts' +import { linkDefinition } from '../mark-spellings.ts' import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { serializeCanonicalJson } from '../../canonical-json.ts' import { tryImageLine } from './inline-line.ts' @@ -16,9 +16,3 @@ export function tryImage(node: AdfNode): string | undefined { const read = centeredImage([externalMedia(alt ?? '', url, link)]) return serializeCanonicalJson(node, 'compact') === serializeCanonicalJson(read, 'compact') ? tryImageLine(alt, url, link) : undefined } - -function linkDefinition({ href, title }: Readonly>): LinkDefinition | undefined { - if (typeof href !== 'string') return undefined - if (title === undefined) return { destination: href } - return typeof title === 'string' ? { destination: href, title } : undefined -} diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index f064f30..ec42022 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -69,16 +69,15 @@ export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, f } // `link` wraps the image as the link's whole text: `[![alt](url)](href)`. -export function tryImageLine(alt: string | undefined, href: string, link?: LinkDefinition): string | undefined { +export function tryImageLine(alt: string | undefined, url: string, link?: LinkDefinition): string | undefined { if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined - const destination = spellDestination(href) + const destination = spellDestination(url) if (destination === undefined) return undefined const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }] if (link === undefined) return attemptLine([syntax('!['), ...description, syntax(`](${destination})`)], 'paragraph', 'lossless').line const target = spellLinkTarget(link.destination, link.title) if (target === undefined) return undefined - const image = [syntax('[!['), ...description, syntax(']('), { escaping: 'bracketed-link-target', text: escapeUnbalanced(destination, '[', ']') } satisfies InlineSegment, syntax(')')] - return attemptLine([...image, syntax(`](${target})`)], 'paragraph', 'lossless').line + return attemptLine([syntax('[!['), ...description, syntax(']('), bracketedLinkTarget(destination), syntax(`)](${target})`)], 'paragraph', 'lossless').line } function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result { @@ -170,6 +169,10 @@ function syntax(text: string): InlineSegment { return { escaping: 'none', text } } +function bracketedLinkTarget(target: string): InlineSegment { + return { escaping: 'bracketed-link-target', text: escapeUnbalanced(target, '[', ']') } +} + function refuseContentAndText(node: AdfNode, path: ConvertErrorPath): Result { const holdsContent = nodeContent(node).length > 0 if (holdsContent || node.text !== undefined) { @@ -344,7 +347,7 @@ function tryLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true }) if (!inner.ok) return inner if (inner.value.carry !== undefined) return inner - const target: InlineSegment = context.bracketed ? { escaping: 'bracketed-link-target', text: escapeUnbalanced(commonMark.target, '[', ']') } : syntax(commonMark.target) + const target = context.bracketed ? bracketedLinkTarget(commonMark.target) : syntax(commonMark.target) return success({ segments: [{ escaping: 'none', nodes: range, text: '[' }, ...inner.value.segments, syntax(']('), target, syntax(')')] }) } diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts index 0f7f7ad..4d1e7e4 100644 --- a/src/markdown/mark-spellings.ts +++ b/src/markdown/mark-spellings.ts @@ -1,7 +1,7 @@ import type { AdfAttributes, AdfMark, AdfNode } from '../adf/document.ts' import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts' -import type { MarkType } from '../adf/mark-attributes.ts' import type { LinkDefinition } from './commonmark/link-syntax.ts' +import type { MarkType } from '../adf/mark-attributes.ts' import { escapeUnbalanced, spellLinkTarget } from './commonmark/link-syntax.ts' import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './directive-syntax.ts' import { holdsEntityReference } from './commonmark/entity-references.ts' @@ -41,6 +41,14 @@ export function linkMark({ destination, title }: LinkDefinition): AdfMark { return { attrs: title === undefined ? { href: destination } : { href: destination, title }, type: 'link' } } +export function linkDefinition(attrs: AdfAttributes): LinkDefinition | undefined { + const destination = linkHref(attrs) + const title = attrs['title'] + if (destination === undefined) return undefined + if (title === undefined) return { destination } + return typeof title === 'string' ? { destination, title } : undefined +} + export function linkHref(attrs: AdfAttributes): string | undefined { const href = attrs['href'] return typeof href === 'string' ? href : undefined diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index 0fcd3e2..75b9855 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -8,6 +8,7 @@ import { flavourClaims, taskMarker } from './conventions.ts' import { inlineLeaves, isBlockNodeType, oneLine, portableLink, reduceInline, writableHref } from './inline-reduction.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { languageSlot } from '../code-language.ts' +import { linkDefinition } from '../mark-spellings.ts' import { largestNesting } from '../../nesting.ts' import { splitTaskList } from './task-list.ts' import { toEditorNormal } from './editor-normal.ts' @@ -350,8 +351,7 @@ 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 image = externalMedia(alt, writableHref(url)) const mark = nodeMarks(media).find((held) => held.type === 'link') const link = mark === undefined ? undefined : portableLink(mark, 'paragraph') - return success([centeredImage([link === undefined ? image : { ...image, marks: [link] }])]) + return success([centeredImage([externalMedia(alt, writableHref(url), link === undefined ? undefined : linkDefinition(nodeAttrs(link)))])]) } diff --git a/todo.md b/todo.md index 7b0cdd3..28de294 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 99` +`Next ID = 101` | Goal | W | |---|---| @@ -26,6 +26,8 @@ | ID | Release | Exempt | Item | R | S | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---| | 89 | 0.2.1 | | **Build a `date`, `emoji`, `inlineCard`, `mention` or `status` holding no mark Atlassian's schema withholds from it, in every reader.** | 4 | 4 | 5 | 6 | 4 | 13.0 | +| 99 | 0.2.1 | defect | **Keep the link target of a linked external image standing inline or in a table cell, in the spelling a reader panel picks, in `adfToPortableMarkdown`.** | 3 | 3 | 6 | 7 | 7 | 12.9 | +| 100 | 0.2.1 | question | **Put the reading of an image's title as its caption back to a reader panel, now that a writer panel reads a title as hover text.** | 3 | 3 | 4 | 5 | 4, 6 | 10.9 | | 60 | 0.2.1 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 2 | 6 | 2 | 10.7 | | 59 | 0.2.1 | 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.1 | 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 | @@ -74,6 +76,19 @@ may reject the document. Replace `InlineNodeModel.takesMarks` (`src/adf/inline-nodes.ts`) with the mark types a node takes, and have the emitter carry a node holding another. +### 99. Keep the link target of a linked external image standing inline or in a table cell, in the spelling a reader panel picks, in `adfToPortableMarkdown`. + +`mediaLeaves` (`src/markdown/portable/inline-reduction.ts`) writes such an image `[alt](url)`, dropping +the link's `href`; README §Portable markdown says link targets stay (Goal 7). `[![alt](url)](href)` +there reads back as `[alt](href)`, so it breaks the byte-for-byte rewrite: keeping one target is the +panel's pick. + +### 100. Put the reading of an image's title as its caption back to a reader panel, now that a writer panel reads a title as hover text. + +Every reader reads `![a](u "t")` as an image captioned `t` (`docs/decisions.md` §An image reads as an +image only alone in its paragraph, 3 of 3 on 2026-10-09). On 2026-10-11 a writer panel of the same +audience kept the captioned image's `!adf:caption` directive, 3 of 3, reading a title as hover text. + ### 60. Collect the questions `parse/` asks `emit/` into one named module. `parse/` asks `emit/` through `commonMarkSpelling` and `openingLinkTakesDirective`, each imported