diff --git a/AGENTS.md b/AGENTS.md index b0fedeb..6ecaf7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ In `docs/decisions.md`: - CommonMark is a subset - Tables - Links -- Every CommonMark image and empty link reads: the image where it stands alone, else a link, `[]()` as nothing +- An image reads as an image only alone in its paragraph, else as its alt text linked; an empty link text reads as its destination - 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 aa4b761..6c63fea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,11 +57,11 @@ `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 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. To keep an image an image, give it a paragraph of its own. See `MIGRATION.md`. +- **Breaking:** `unmappable-image` leaves `ConvertErrorCode`, and every image reads. An image reads + as an image only alone in its paragraph, and its title becomes the caption. Anywhere else it + reads as its alt text linked to its URL; inside a link, as plain text the link marks. A link + alone in its paragraph whose whole text is one image reads as that image, with the link marking + it. To keep an image an image, give it a paragraph of its own. See `MIGRATION.md`. - **Breaking:** an empty link text reads as its destination, `[](/url)` as `/url` linked, and `[]()` as nothing, where both spellings stayed literal text. See `MIGRATION.md`. - Fix a JSON key holding an escape, in a carried node or a JSON attribute such as `parameters`: diff --git a/MIGRATION.md b/MIGRATION.md index a1fa028..3daa88d 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -74,14 +74,14 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc `unmappable-image`, `unspellable-character`, `unspellable-line-start` and `unspellable-link` leave `ConvertErrorCode` and `not-a-string` joins it: a `switch` naming one of the four stops compiling, and one naming every code needs the new case. A document `adfToMarkdown` refused with one of the -last three now converts in `adfToLosslessMarkdown`; markdown the parser refused with one of the -four now reads, or refuses with the code the table gives. +three `unspellable-` codes now converts in `adfToLosslessMarkdown`; markdown the parser refused +with one of the four now reads, or refuses with the code the table gives. | Input | `0.1.0` | `0.2.0` | | --- | --- | --- | -| 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; give the image a paragraph of its own, with a blank line between it and any text, to keep it an image | +| an image not alone in its 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; give the image a paragraph of its own, a blank line between it and any other line, to keep it an image | | 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 alone in its paragraph whose whole text is an image (`[![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}` | | a carriage return in text, on emit | `unspellable-character` | spells ` ` | | a null character in text, on emit | `unspellable-character` | spells `!adf:text{text="\u0000"}` | diff --git a/README.md b/README.md index abed14c..efbecfd 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,7 @@ products store as ADF. - **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 nesting past 500 - levels, and `portableMarkdownToAdf` a claimed shape that does not complete - ([The errors](#the-errors)). + levels; `portableMarkdownToAdf` also refuses a malformed pipe table ([The errors](#the-errors)). - **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. @@ -239,16 +238,17 @@ Serves Goals 1, 4 and 5. without a delimiter row, is an error result; each can be kept literal (`spec/flavour.md`). - A document any reader built, written back with `adfToLosslessMarkdown`, takes the library's canonical spelling, which round-trips byte-identically. -- Six CommonMark spellings parse without an error and build a document the reference - implementation renders differently. Three because ADF holds no node for what it renders: +- Seven CommonMark spellings parse without an error and build a document the reference + implementation renders differently. Four 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 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 — unless the link's whole text is the image and the link stands alone in a - plain paragraph, which reads as the image the link marks. To keep an image an image, give it - a paragraph of its own: nothing else on its line, and a blank line between it and any text - above or below. Images on consecutive lines share a paragraph. + - 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 + 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. + - Inside a link, an image reads as plain text the link marks. A link alone in its paragraph + whose whole text is one image reads as that image, with the link marking it. - An image's title becomes its caption when the image stays an image, and its link's title when it becomes linked alt text. Inside another link, the title is dropped. @@ -256,8 +256,8 @@ Serves Goals 1, 4 and 5. a shortcut reference matching its definition only under Unicode case folding stays unresolved, and a link whose text holds an autolink keeps the inner link and leaves the outer brackets literal text, which the spec requires and the reference itself breaks, nesting one `` in the - other. `corpus/commonmark-spec/exceptions.json` pins the first three `node-model` and the next - two `pending`; the suite holds no example of the last. + other. `corpus/commonmark-spec/exceptions.json` pins the first four `node-model` where the suite + reaches them, and the next two `pending`; the suite holds no example of the last. - Not every document converts back: `adfToLosslessMarkdown` refuses a document nesting past 500 levels, and the node shapes the `unsupported-node-shape` row lists. Show the refusal and keep the document read-only; saving markdown you could not produce is the loss the round-trip exists to diff --git a/corpus/README.md b/corpus/README.md index c4c6a12..db1a2f7 100644 --- a/corpus/README.md +++ b/corpus/README.md @@ -17,8 +17,7 @@ One directory per contract kind: `exceptions.json` pins each known divergence by `check`, `example`, `kind` and the exact `divergence`, with a `reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-node model), `node-model` (a permanent divergence where ADF holds no node for what - CommonMark renders: an empty link text, an image amid text, an image's title) or `pending` (a - parser gap). + CommonMark renders) or `pending` (a parser gap). JSON is two-space indent, keys sorted, and a document read back must deep-equal the fixture's (`docs/decisions.md` §Equality is deep). `spec.json` is the vendored, upstream machine-readable diff --git a/docs/decisions.md b/docs/decisions.md index 329beea..1846167 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -202,19 +202,20 @@ 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. -## Every CommonMark image and empty link reads: the image where it stands alone, else a link, `[]()` as nothing +## An image reads as an image only alone in its paragraph, else as its alt text linked; an empty link text reads as its destination -2026-10-04, the maintainer and a writer panel, 3 of 3; `[]()` 2026-10-09, settled by Goals 4 and 7 -as the maintainer's plan item directed. Goals 4, 5, 6 and 7. Valid while `mediaInline` carries no -URL and ADF holds no empty text node. +2026-10-04, the maintainer and a writer panel, 3 of 3; `[]()` 2026-10-09, by Goals 4 and 7, as the +maintainer's plan item asked. Goals 4, 5, 6 and 7. 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 and title drop: the panel chose this by Goal 6, which outranks Goal 7. +Every reader reads every CommonMark image and empty link, as `spec/flavour.md` §The CommonMark +image and §Marks spell out. An image alone in its paragraph reads as the image, its title as the caption. +Anywhere else it 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 link alone in its paragraph whose whole text is one image +reads as that image, with the link marking its `media`. Inside any other link, the alt text is plain +text the link marks, and the image's URL and title drop: the panel chose this by Goal 6, which +outranks Goal 7. ## Ids stay site-local diff --git a/spec/flavour.md b/spec/flavour.md index b757532..5ebb2af 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -314,18 +314,18 @@ the destination, `alt` the description's plain-text content when non-empty — a it contributing its own text, a node spelling its text in the content slot contributing that text, and a break of either kind a space. A non-empty title, `![alt](url "title")`, adds a `caption` holding the title as one text node, and a link whose entire text is the image, -`[![alt](url)](href)`, marks the `media`. `adfToLosslessMarkdown` emits the image form for exactly -the shape with neither — those attrs and no others, no marks on either node, no caption, and a +`[![alt](url)](href)`, marks the `media`. `adfToLosslessMarkdown` emits the image form only for the +bare shape — those attrs and no others, no marks on either node, no caption, and a `media` carrying nothing beyond `alt`, `type` and `url` — and only where CommonMark spells the pair: a destination or a description the image form cannot hold, an empty `alt` included, takes the 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, the alt text is plain text the link -marks, and the image's destination and title drop (`docs/decisions.md` §Every CommonMark image and -empty link reads). +`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 +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). ### Tables diff --git a/src/conformance/commonmark-spec.test.ts b/src/conformance/commonmark-spec.test.ts index 1d701fc..6c81a49 100644 --- a/src/conformance/commonmark-spec.test.ts +++ b/src/conformance/commonmark-spec.test.ts @@ -92,7 +92,7 @@ test('the refusal list is unique per example and names real examples', () => { for (const example of exampleToRefusal.keys()) assert.ok(spec.some((entry) => entry.example === example), `refusal ${example} names no example in the suite`) }) -// A mark is counted once per node it touches (docs/decisions.md §No schema validation). +// A mark counts once per node carrying it: ADF marks each node where HTML nests one element (docs/decisions.md §No schema validation). const countKeys = ['a', 'blockquote', 'br', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img', 'li', 'ol', 'pre', 'strong', 'ul'] const nodeElement: Record = { blockquote: 'blockquote', diff --git a/src/markdown/external-image.ts b/src/markdown/external-image.ts index 348e0d2..38a122b 100644 --- a/src/markdown/external-image.ts +++ b/src/markdown/external-image.ts @@ -1,6 +1,6 @@ import type { AdfNode } from '../adf/document.ts' -// spec/flavour.md, The CommonMark image: `![alt](url)` reads to `centeredImage([externalMedia(alt, url)])`, the only shape written back as it. +// spec/flavour.md, The CommonMark image: `![alt](url)` reads to `centeredImage([externalMedia(alt, url)])`, the only shape `adfToLosslessMarkdown` writes as `![alt](url)`. export function centeredImage(content: AdfNode[]): AdfNode { return { attrs: { layout: 'center' }, content, type: 'mediaSingle' } } diff --git a/src/markdown/parse/inline-content.ts b/src/markdown/parse/inline-content.ts index a25d06a..020685e 100644 --- a/src/markdown/parse/inline-content.ts +++ b/src/markdown/parse/inline-content.ts @@ -22,7 +22,7 @@ import { readDirectiveMark } from './directive-marks.ts' import { readInlineDirectiveNode } from './directive-nodes.ts' import { readTextDirective } from '../text-directive.ts' -// `image` is the `mediaSingle` a lone image builds, for the one container that holds it: a plain paragraph. `nodes` reads it as its linked alt text. +// Only a `paragraph` block takes `image`, the `mediaSingle` a lone image builds; every other container takes `nodes`, where the image is its linked alt text. export type InlineContent = { image?: AdfNode; nodes: AdfNode[] } // The text break leaf: it holds no marks, and stands between the nodes it parts until the outermost content checks and drops it. diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index c522e26..ff6a73e 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -318,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 a lone image the block it needs. +// spec/flavour.md, The CommonMark image: only a `paragraph` block 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 diff --git a/todo.md b/todo.md index dcf479d..3c57862 100644 --- a/todo.md +++ b/todo.md @@ -28,7 +28,7 @@ | 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 | +| 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 | | 85 | 0.2.0 | defect | **Write an external image a link marks as `[![alt](url)](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 | @@ -81,7 +81,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 "Six CommonMark spellings" bullet, which counts one fewer. +spelling leaves the README's "Seven 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. @@ -91,11 +91,12 @@ 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 `[![alt](url)](href)`, and one captioned with plain text as `![alt](url "caption")`, in `adfToLosslessMarkdown`. +### 86. 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`. -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. +Every reader reads `[![alt](url)](href)` and `![alt](url "caption")` as these shapes, and +`adfToLosslessMarkdown` writes the `!adf:mediaSingle` directive for both. The emitted form is API +(`docs/decisions.md` §The formats are API): changing it inside `0.2.0` spares consumers a second +breaking change to stored markdown. ### 85. Write an external image a link marks as `[![alt](url)](href)` in `adfToPortableMarkdown`, keeping the link target. @@ -248,8 +249,8 @@ 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 "Six CommonMark -spellings" bullet, which counts one fewer. +gains its row. Its `pending` exceptions go, and its spelling leaves the README's "Seven +CommonMark spellings" bullet, which counts one fewer. ### 53. Put a block's `marks` spelling to the writer panel and adopt its pick. @@ -324,9 +325,8 @@ 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. +Once `Scan`'s pieces have owners, the bracket resolution, `closeBracket` through `altText`, can +leave `inline-content.ts` (about 690 lines) 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.