mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 11:48:29 +01:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
+6
-5
@@ -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`,
|
||||
|
||||
+1
-1
@@ -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 .`, `# `), 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 .`, `# `), 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 (``), on parse | `unmappable-image` | the image, captioned with its title |
|
||||
| an image that is a link's whole text, alone in its paragraph (`[](/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}` |
|
||||
|
||||
@@ -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 .` 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 .` 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,
|
||||
|
||||
@@ -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 `<a>` 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.
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: `` 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { AdfNode } from '../adf/document.ts'
|
||||
|
||||
// spec/flavour.md, The CommonMark image: the shape `` 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' }
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1027,9 +1027,11 @@ test('reads an image no paragraph holds alone as its alt text, linked to its des
|
||||
assert.deepEqual(firstContent('# \n'), [marked('a', link('/u'))])
|
||||
assert.deepEqual(firstContent('!adf:paragraph {localId=p}\n\n!adf:/paragraph\n'), [marked('a', link('/u'))])
|
||||
assert.deepEqual(firstContent(' 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 [](/v).\n'), [text('See '), marked('a', link('/v')), text('.')])
|
||||
assert.deepEqual(firstContent('See [](/v).\n'), [text('See '), marked('/v', link('/v')), text('.')])
|
||||
assert.deepEqual(firstContent('[b ](/v)\n'), [marked('b a', link('/v'))])
|
||||
|
||||
@@ -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<AdfNode> {
|
||||
@@ -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
|
||||
|
||||
@@ -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<AdfNode[]> {
|
||||
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))])])
|
||||
}
|
||||
|
||||
@@ -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 `[](href)`, and one captioned with plain text as ``, 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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user