From 66e4c7e8b52f533164ed1e4071f8f3289e6c2639 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 9 Oct 2026 02:36:06 +0200 Subject: [PATCH] Document the three markdown readers: README, MIGRATION, CHANGELOG, the grammar, the decisions and the plan --- AGENTS.md | 6 +-- CHANGELOG.md | 45 ++++++++++-------- MIGRATION.md | 32 ++++++++----- README.md | 113 +++++++++++++++++++++++++--------------------- corpus/README.md | 18 ++++---- docs/decisions.md | 74 +++++++++++++++--------------- spec/flavour.md | 65 +++++++++++++------------- todo.md | 106 ++++++++++++++++++++----------------------- 8 files changed, 235 insertions(+), 224 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 63f7e6c..3eae90e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,9 +26,9 @@ In `docs/decisions.md`: - Tables - Links - Ids stay site-local -- Plain task ids come from position +- Portable task ids come from position - A callout title keeps its link targets -- The plain flavour's spellings +- The portable flavour's spellings - The HTML dialect - No runtime dependencies - Standards ship as data @@ -205,4 +205,4 @@ The comprehension panel's fill-ins: - Domain: Atlassian Document Format and CommonMark parsing. - Domain docs: the CommonMark spec, ADF's JSON schema and `spec/flavour.md`. - 3am question: a viewer/editor app reports that a document it saved comes back with two text - nodes merged and a mark gone after `markdownToAdf(adfToMarkdown(doc))`. + nodes merged and a mark gone after `losslessMarkdownToAdf(adfToLosslessMarkdown(doc))`. diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b1a17..d2d9ac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,12 @@ ## Unreleased -- **Breaking:** `markdownToAdf` and `plainMarkdownToAdf` refuse a value that is not a string with - the new code `not-a-string`; `MIGRATION.md` lists what each value did before. +- **Breaking:** each call names the markdown it reads or writes. `adfToMarkdown` and `markdownToAdf` + are now `adfToLosslessMarkdown` and `losslessMarkdownToAdf`, and the new `commonMarkToAdf` reads + markdown a person or a model wrote as the CommonMark spec says: a directive, a pipe table, `~~` + and an `adf:` fence read as text and code. `MIGRATION.md` says which call to take. +- **Breaking:** the markdown readers refuse a value that is not a string with the new code + `not-a-string`; `MIGRATION.md` lists what each value did before. - **Breaking:** an attribute value that is not a plain object, array or JSON primitive, such as a `Date`, `Map`, typed array or class instance, makes `isAdfDocument` return `false` and the converters refuse it as `not-an-adf-document`. Before, the converters wrote it as `{}` or its own @@ -20,19 +24,20 @@ - **Breaking:** the block carry is a code fence whose info string `adf:` names the node's type, its body the node's JSON without `type`, and a code fence whose info string opens `adf:` is claimed, and `adf` is an ordinary code block language. Convert stored markdown per `MIGRATION.md`. -- **Breaking:** `markdownToAdf` and `plainMarkdownToAdf` read markdown holding no block as a - document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}` - spells a document holding no `content` key. -- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as `JSON.parse` builds it: two adjacent text - nodes CommonMark would read back as one are parted by `!adf:textBreak{}`, an empty `attrs`, - `content` or `marks` is spelled `{attrs=empty}`, `{content=empty}` or `{marks=empty}`, `-0` is - spelled `-0`, and a `codeBlock` of several text nodes is a fence per node. A `codeBlock` holding - other than plain text nodes rides the block carry, where it was refused. +- **Breaking:** the markdown readers read markdown holding no block as a document whose `content` is + empty, as Atlassian's schema requires; `!adf:doc {content=none}` spells a document holding no + `content` key. +- `losslessMarkdownToAdf(adfToLosslessMarkdown(doc))` deep-equals `doc` as `JSON.parse` builds it: + two adjacent text nodes CommonMark would read back as one are parted by `!adf:textBreak{}`, an + empty `attrs`, `content` or `marks` is spelled `{attrs=empty}`, `{content=empty}` or + `{marks=empty}`, `-0` is spelled `-0`, and a `codeBlock` of several text nodes is a fence per + node. A `codeBlock` holding other than plain text nodes rides the block carry, where it was + refused. - **Breaking:** `unspellable-character` and `unspellable-line-start` leave `ConvertErrorCode`, and - `adfToMarkdown` no longer returns `unspellable-whitespace`. A carriage return in text is spelled - ` ` and a null character `!adf:text{text="\u0000"}`; a paragraph line opening with a code span - is spelled as that code span, its backticks opening no fence; a code block holding either - character, a code span holding a null character, a text node holding no text or holding + `adfToLosslessMarkdown` no longer returns `unspellable-whitespace`. A carriage return in text is + spelled ` ` and a null character `!adf:text{text="\u0000"}`; a paragraph line opening with a + code span is spelled as that code span, its backticks opening no fence; a code block holding + either character, a code span holding a null character, a text node holding no text or holding `content`, and an `emoji`, `mention` or `status` whose `text` holds a line ending or a null character ride the carry. See `MIGRATION.md`. - Fix a link `title` holding a null character: it is written as `!adf:link[text]{attrs}`, where it @@ -44,7 +49,7 @@ `MIGRATION.md` lists each. - **Breaking:** a link whose text holds another link keeps the inner link and leaves the outer brackets literal text, where `0.1.0` split the outer link around it; see `MIGRATION.md`. -- Add `adfToPlainMarkdown` and `plainMarkdownToAdf`, a lossy pair converting ADF to and from +- Add `adfToPortableMarkdown` and `portableMarkdownToAdf`, a lossy pair converting ADF to and from markdown GitHub, GitLab and Obsidian render: alerts, callouts, task lists, `==highlights==` and pipe tables. - Spell `rule`'s `color`, `style` and `weight`, `layoutSection`'s `columnRuleStyle` and a link's @@ -52,12 +57,12 @@ - Fix an image inside another image's description: it flattens into the alt text, where it was refused. - Fix a JSON key holding an escape, in a carried node or a JSON attribute such as `parameters`: - `markdownToAdf` and `plainMarkdownToAdf` read it as the key it spells, where on V8 13.6 and later - (Node 24, Deno, Chrome) they could refuse markdown `adfToMarkdown` wrote, as - `unsupported-node-shape`, once the process had read a key holding a backslash + `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`, + once the process had read a key holding a backslash ([V8 bug](https://issues.chromium.org/issues/521080746)). -- Fix a cyclic document, in which an object holds itself: `adfToMarkdown` and `adfToPlainMarkdown` - refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, where all three hung. +- Fix a cyclic document, in which an object holds itself: `adfToLosslessMarkdown` and + `adfToPortableMarkdown` refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, where all three hung. ## 0.1.0 diff --git a/MIGRATION.md b/MIGRATION.md index 171aa2e..596805d 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2,12 +2,20 @@ ## From `0.1.0` to `0.2.0` -Directives moved under the `!adf:` prefix. `0.2.0` reads `0.1.0`'s spelling without an error, -turning each directive into text and each carried node into an `adf` code block. Before `0.2.0` -reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the -recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by the -tables below. Stored ADF needs one change: give a document holding no `content` key `content: []`. -`0.1.0` built that shape from empty markdown, meaning the empty document. +Each call names the markdown it reads or writes: + +| `0.1.0` | `0.2.0` | +| --- | --- | +| `adfToMarkdown` | `adfToLosslessMarkdown` | +| `markdownToAdf`, for markdown `adfToMarkdown` wrote | `losslessMarkdownToAdf` | +| `markdownToAdf`, for markdown a person or a model wrote | `commonMarkToAdf`, which reads a directive, a pipe table, `~~` and an `adf:` fence as CommonMark does, as text and code | + +Directives moved under the `!adf:` prefix. `losslessMarkdownToAdf` reads `0.1.0`'s spelling without +an error, turning each directive into text and each carried node into an `adf` code block. Before +`0.2.0` reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) +with the recipe below, and rewrite markdown your code writes or matches (templates, prompts, +patterns) by the tables below. Stored ADF needs one change: give a document holding no `content` key +`content: []`. `0.1.0` built that shape from empty markdown, meaning the empty document. ### Convert markdown @@ -18,13 +26,13 @@ npm install @larvit/adf-codec@0.2.0 adf-codec-0.1@npm:@larvit/adf-codec@0.1.0 ``` ```ts -import { adfToMarkdown } from '@larvit/adf-codec' +import { adfToLosslessMarkdown } from '@larvit/adf-codec' import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1' function migrateMarkdown(stored: string) { const parsed = markdownToAdf010(stored) // 0.1.0 dropped an empty content array, so a document with no content key meant an empty one. - return parsed.ok ? adfToMarkdown({ ...parsed.value, content: parsed.value.content ?? [] }) : parsed + return parsed.ok ? adfToLosslessMarkdown({ ...parsed.value, content: parsed.value.content ?? [] }) : parsed } ``` @@ -57,15 +65,15 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc | Input | `0.1.0` | `0.2.0` | | --- | --- | --- | | a link whose text already holds one (`[ab](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them | -| markdown holding no block (`markdownToAdf("")`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former | -| a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | the block carry, refusing a body that is not one node's canonical JSON; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block | +| markdown holding no block (`""`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former | +| a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | in `losslessMarkdownToAdf`, the block carry, refusing a body that is not one node's canonical JSON; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block | ### Error codes `unspellable-character`, `unspellable-line-start` and `unspellable-link` leave `ConvertErrorCode` and `not-a-string` joins it: a `switch` naming one of the three stops compiling, and one naming -every code needs the new case. A document `adfToMarkdown` refused with one of the three now -converts; markdown the parser refused with one of the three now refuses with the code the table +every code needs the new case. A document `adfToMarkdown` refused with one of the three +`adfToLosslessMarkdown` now converts; markdown the parser refused with one of the three now refuses with the code the table gives. | Input | `0.1.0` | `0.2.0` | diff --git a/README.md b/README.md index cfdd6cc..9fc0c57 100644 --- a/README.md +++ b/README.md @@ -8,22 +8,22 @@ Convert Atlassian Document Format (ADF) to and from markdown and HTML, with a lo For application developers whose app shows, edits, exports or generates the rich text Atlassian products store as ADF. -- **Lossless:** `adfToMarkdown` and `markdownToAdf` give back the document they started from — - panels, mentions, statuses and node types this version does not know included. -- **Plain CommonMark in:** a bot's or a model's markdown is valid input, apart from raw HTML and - the exceptions [The guarantees](#the-guarantees) lists. -- **Plain markdown, lossy:** `adfToPlainMarkdown` and `plainMarkdownToAdf` 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. `adfToPlainMarkdown` - keeps the text, images by URL and link targets and drops the rest; to save edits back, use the - lossless pair. +- **Lossless:** `adfToLosslessMarkdown` and `losslessMarkdownToAdf` give back the document they + started from — panels, mentions, statuses and node types this version does not know included. +- **CommonMark in:** `commonMarkToAdf` reads a bot's or a model's markdown as the CommonMark spec + says, apart from raw HTML and the exceptions [The guarantees](#the-guarantees) lists. +- **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. + `adfToPortableMarkdown` keeps the text, images by URL and link targets and drops the rest; to + save edits back, use the lossless pair. - **No throws:** every call returns a result; a refusal carries a code from a closed list. - **Runs anywhere:** pure functions with no runtime dependencies, in Node, Deno, Bun and browsers. - **HTML at `0.3.0`:** `adfToHtml`, `htmlToAdf`, `markdownToHtml`, `htmlToMarkdown`. [The shape](#the-shape) · [Goals](#goals) · [Audience](#audience) · -[Plain markdown](#plain-markdown) · [The errors](#the-errors) · [The guarantees](#the-guarantees) · -[The package](#the-package) · +[Portable markdown](#portable-markdown) · [The errors](#the-errors) · +[The guarantees](#the-guarantees) · [The package](#the-package) · [Grammar](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md) · [Changes](https://github.com/larvit/adf-codec/blob/main/CHANGELOG.md) · [Upgrading from `0.1.0`](https://github.com/larvit/adf-codec/blob/main/MIGRATION.md) · @@ -37,9 +37,9 @@ npm install @larvit/adf-codec ``` ```ts -import { markdownToAdf } from '@larvit/adf-codec' +import { losslessMarkdownToAdf } from '@larvit/adf-codec' -const result = markdownToAdf('# Release notes\n\n!adf:panel info\nShipped on Tuesday.\n!adf:/panel\n') +const result = losslessMarkdownToAdf('# Release notes\n\n!adf:panel info\nShipped on Tuesday.\n!adf:/panel\n') if (result.ok) { send(result.value) } else { @@ -50,15 +50,17 @@ if (result.ok) { Serves [Goals](#goals) 1, 2 and 8. Pure functions, each taking a whole document and returning a whole result; no I/O, no configuration. `markdownToHtml` and `htmlToMarkdown` convert through ADF: -they keep only what ADF holds, and refuse what `markdownToAdf` or `htmlToAdf` refuses. +they keep only what ADF holds, and refuse what `losslessMarkdownToAdf` or `htmlToAdf` refuses. ```ts -adfToMarkdown(doc: AdfDocument): Result -markdownToAdf(markdown: string): Result +adfToLosslessMarkdown(doc: AdfDocument): Result +losslessMarkdownToAdf(markdown: string): Result isAdfDocument(v: unknown): v is AdfDocument -adfToPlainMarkdown(doc: AdfDocument): Result -plainMarkdownToAdf(markdown: string): Result +commonMarkToAdf(markdown: string): Result + +adfToPortableMarkdown(doc: AdfDocument): Result +portableMarkdownToAdf(markdown: string): Result adfToHtml(doc: AdfDocument): Result // 0.3.0 htmlToAdf(html: string): Result // 0.3.0 @@ -103,22 +105,22 @@ Behind those apps, the people who read and write the markdown, and later the HTM managers, engineers and support agents working in Atlassian products through a plugin or another UI. They know markdown and not ADF, and rely on every spelling saying what it means to them. -## Plain markdown +## Portable markdown -Serves Goal 7. Plain markdown is a second flavour of the same grammar. `adfToPlainMarkdown` writes -markdown other tools render — GitHub, GitLab, Obsidian and the like — keeping the content and +Serves Goal 7. Portable markdown is a second flavour of the same grammar. `adfToPortableMarkdown` +writes markdown other tools render — GitHub, GitLab, Obsidian and the like — keeping the content and dropping the rest: attributes, colours, layout, identity. Content is what a reader of the rendered -document sees or follows: its text, images and link targets. It refuses only -`not-an-adf-document`, `unsupported-document-version` and `unsupported-nesting-depth`, and writes -no directive. +document sees or follows: its text, images and link targets. It refuses only `not-an-adf-document`, +`unsupported-document-version` and `unsupported-nesting-depth`, and writes no directive. -`plainMarkdownToAdf` reads what `markdownToAdf` reads and refuses what it refuses, and reads the -conventions below as nodes, taking other tools' spellings too; a backslash keeps a marker as text: -`\==x==`, `> \[!NOTE]`, `- \[x]`. Markdown `adfToPlainMarkdown` wrote reads back and writes again +`portableMarkdownToAdf` reads CommonMark, pipe tables and `~~` as `losslessMarkdownToAdf` reads +them, and the conventions below as nodes, taking other tools' spellings too; a directive and an +`adf:` fence read as CommonMark reads them, as text and code. A backslash keeps a marker as text: +`\==x==`, `> \[!NOTE]`, `- \[x]`. Markdown `adfToPortableMarkdown` wrote reads back and writes again byte for byte; the document it came from does not come back. -To edit a document and save it back, use `adfToMarkdown` and `markdownToAdf`: saving what this pair -read replaces mentions, attachments and macros with text. +To edit a document and save it back, use `adfToLosslessMarkdown` and `losslessMarkdownToAdf`: +saving what this pair read replaces mentions, attachments and macros with text. | ADF | Written | Read back | | --- | --- | --- | @@ -165,8 +167,8 @@ carries it opaquely and restores it unchanged ([`docs/decisions.md`](https://git `ConvertError` is `{ code, message, path, position? }`. `code` is the exported `ConvertErrorCode`, stable across minors and safe to `switch` on exhaustively with no `default`; `message` is free text -and may change in any release. A parse always names a position, so `markdownToAdf` and -`plainMarkdownToAdf` return `ParseError`, whose `position` reads without a guard; an emit reads no +and may change in any release. A parse always names a position, so the three markdown readers +return `ParseError`, whose `position` reads without a guard; an emit reads no source and carries `path` alone; one handler typed on `ConvertError` takes both, which is what the composed `markdownToHtml` and `htmlToMarkdown` hand back. `path` is the node's place from the document root, alternating `'content'` and an index, so `path.map((step) => '/' + step).join('')` @@ -181,7 +183,9 @@ UTF-16 code unit, a JavaScript string index rather than a codepoint or a byte of or before the refusal — currently the start of the line the enclosing block begins on; a later minor may narrow that, never widen it. For `not-a-string` it is `{ line: 1, offset: 0 }`. -Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.3.0`: +Parsing — `losslessMarkdownToAdf` raises every row, `portableMarkdownToAdf` the rows naming no +directive, and `commonMarkToAdf` `not-a-string`, `unmappable-html` and `unmappable-image`; +`htmlToAdf` at `0.3.0`: | Code | Fires when | What you can do | | --- | --- | --- | @@ -193,7 +197,7 @@ Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.3.0` | `unmappable-image` | an image sits inside other content that is not another image's description, or carries a title | give the image a paragraph of its own and drop the title | | `unspellable-whitespace` | an `emoji`, `mention` or `status` directive's content slot spells a newline or a carriage return — ` `, ` `, `!adf:text{text="\n"}` | replace it with a space, or write the node as `!adf:carry` — an inline directive never spans lines | -Emitting — `adfToMarkdown`, and `adfToHtml` at `0.3.0`: +Emitting — `adfToLosslessMarkdown` and `adfToPortableMarkdown`, and `adfToHtml` at `0.3.0`: | Code | Fires when | What you can do | | --- | --- | --- | @@ -207,25 +211,28 @@ emit refuses: | Code | Fires when | What you can do | | --- | --- | --- | | `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | -| `unsupported-node-shape` | parsing: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node carrying one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content` | fix what the message names; `spec/flavour.md` lists every type's attributes and body | +| `unsupported-node-shape` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node carrying one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content` | fix what the message names; `spec/flavour.md` lists every type's attributes and body | ## The guarantees Serves Goals 1, 4 and 5. -- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as JSON, for a document of plain objects - as `JSON.parse` builds them — every key and value as `doc` holds it, adjacent text nodes, an - empty `attrs`, `content` or `marks` and `-0` included, and unknown node types carried opaquely +- `losslessMarkdownToAdf(adfToLosslessMarkdown(doc))` deep-equals `doc` as JSON, for a document of + plain objects as `JSON.parse` builds them — every key and value as `doc` holds it, adjacent text + nodes, an empty `attrs`, `content` or `marks` and `-0` included, and unknown node types carried + opaquely ([`docs/decisions.md`](https://github.com/larvit/adf-codec/blob/main/docs/decisions.md#unknown-nodes-ride-the-carry)). - Markdown this library reads, and markdown it writes, means what the CommonMark spec says; from `0.3.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below name every exception. -- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html` - names, with four carve-outs — literal text matching directive, pipe-table or strikethrough syntax, - and a code fence whose info string opens `adf:`, are claimed (each can be kept literal — - `spec/flavour.md`) — and one gap: a CommonMark image fits only as its own title-less paragraph; - mid-text and titled images are error results, save an image inside another's description, which - flattens into the alt text. Converting back yields the library's canonical spelling, which +- CommonMark is valid input to every reader apart from the raw HTML `unmappable-html` names, with + one gap: a CommonMark image fits only as its own title-less paragraph; mid-text and titled images + are error results, save an image inside another's description, which flattens into the alt text. + `commonMarkToAdf` reads it as its spec says. `losslessMarkdownToAdf` has four carve-outs — + literal text matching directive, pipe-table or strikethrough syntax, and a code fence whose info + string opens `adf:`, are claimed (each can be kept literal — `spec/flavour.md`) — and + `portableMarkdownToAdf` claims what [Portable markdown](#portable-markdown) lists. Converting a + read document back with `adfToLosslessMarkdown` yields the library's canonical spelling, which round-trips byte-identically. - Four CommonMark spellings parse without an error and build a document the reference implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's @@ -235,15 +242,17 @@ Serves Goals 1, 4 and 5. text, which the spec requires and the reference itself breaks, nesting one `` in the other. The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite holds no example of the fourth. -- Not every document converts back: `adfToMarkdown` 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 stop. -- The pipe table narrows GFM's twice: every row opens with a pipe, so GFM's bare form is an error - result rather than the prose it reads as, and an alignment colon in the delimiter row is an - error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in - input. -- Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist - is the `taskList` directive — `plainMarkdownToAdf` turns the marker into a `taskList`. +- 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 + stop. +- The pipe table `losslessMarkdownToAdf` and `portableMarkdownToAdf` read narrows GFM's twice: every + row opens with a pipe, so GFM's bare form is an error result rather than the prose it reads as, + and an alignment colon in the delimiter row is an error too — ADF holds no column alignment. The + trailing pipe is canonical output, optional in input. +- Past that and `~~`, `losslessMarkdownToAdf` reads no GFM: an autolink literal and a `- [ ]` + marker stay text, and a checklist is the `taskList` directive — `portableMarkdownToAdf` turns the + marker into a `taskList`. - A document nested deeper than 500 levels is an error result, not a stack overflow, and no call loops forever on a string, or on a value `JSON.parse` or `structuredClone` builds. - The emitted formats are semver surface diff --git a/corpus/README.md b/corpus/README.md index 1c23a45..5b969a9 100644 --- a/corpus/README.md +++ b/corpus/README.md @@ -2,21 +2,21 @@ One directory per contract kind: -- `round-trip/` — `.json` + `.md`: the markdown `adfToMarkdown` must emit for that - document, byte for byte, and that `markdownToAdf` must read back to it (`docs/decisions.md` §The - round-trip is the product). Grouped by what the fixture exercises. +- `round-trip/` — `.json` + `.md`: the markdown `adfToLosslessMarkdown` must emit for + that document, byte for byte, and that `losslessMarkdownToAdf` must read back to it + (`docs/decisions.md` §The round-trip is the product). Grouped by what the fixture exercises. - `normalization/` — `.md` + `.json`: markdown input, and the document - `markdownToAdf` must build from it, which must in turn emit and read back to itself. The + `losslessMarkdownToAdf` must build from it, which must in turn emit and read back to itself. The markdown is not canonical. - `errors/` — `.md`: markdown input that must not convert. A `.error` beside it pins which error. - `real-payloads/` — `.json`: ADF Atlassian's editor wrote, round-tripped ADF→markdown→ADF. No expected markdown. -- `commonmark-spec/` — the CommonMark suite run against `markdownToAdf` by three checks. `spec.json` - is the suite; `refusals.json` pins each refusing example to its error `code`; `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-text-node - model) or `pending` (a parser gap). +- `commonmark-spec/` — the CommonMark suite run against `commonMarkToAdf` by three checks. + `spec.json` is the suite; `refusals.json` pins each refusing example to its error `code`; + `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-text-node model) 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 c8745c8..d7a1562 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -32,12 +32,12 @@ writer. 2026-08-23, real payloads 2026-09-15, the maintainer. Goal 1. Valid while a consumer saves back through the lossless pair. -`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must deep-equal `doc` — anything -less silently destroys content an editor could not represent, in a document it did not author. -When losslessness and readability conflict, losslessness wins. Round-trip equality is a property -tested over a checked-in corpus (`corpus/README.md`), not a claim made in prose. Its real payloads -are invented content written in Atlassian's editor on the maintainer's test site, so none is -sanitized and a mention keeps the test user's real account id. +`losslessMarkdownToAdf(adfToLosslessMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must deep-equal +`doc` — anything less silently destroys content an editor could not represent, in a document it did +not author. When losslessness and readability conflict, losslessness wins. Round-trip equality is a +property tested over a checked-in corpus (`corpus/README.md`), not a claim made in prose. Its real +payloads are invented content written in Atlassian's editor on the maintainer's test site, so none +is sanitized and a mention keeps the test user's real account id. ## Markdown in is a canonical fixpoint @@ -55,11 +55,12 @@ shape the editor would not. "Equals" is deep equality over the document's JSON values: `assert.deepStrictEqual` on plain objects as `JSON.parse` builds them, since ADF is JSON. Every key and value in `doc` counts, including two adjacent text nodes, an empty `attrs`, `content` or `marks`, and `-0`. Neither side is normalized. -CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader builds -what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction reads and -writes editor-normal ADF — adjacent text nodes of identical marks and no attributes merged, `-0` as -`0`, and an empty `attrs`, `content` or `marks` the absent key, except the doc's `content`, which -ADF's schema requires — so two documents the editor holds equal write the same plain markdown. +CommonMark's spelling stays wherever a document holds none of those shapes. The portable reader +builds what is written, as `losslessMarkdownToAdf` does. Only the portable writer is lossy: its +reduction reads and writes editor-normal ADF — adjacent text nodes of identical marks and no +attributes merged, `-0` as `0`, and an empty `attrs`, `content` or `marks` the absent key, except +the doc's `content`, which ADF's schema requires — so two documents the editor holds equal write the +same portable markdown. ## `!adf:textBreak{}` parts text CommonMark would join @@ -138,8 +139,8 @@ language, and none is empty beside another, since a text node holds text. The gr 2026-08-23, the sort 2026-09-20, the maintainer. Goals 1 and 5. Valid while ADF holds no node for a bare container, a comment or a script. Lands with `todo.md` item 6. -Every foreign element `htmlToAdf` and `markdownToAdf` read sorts one of three ways, never a silent -drop of content: +Every foreign element `htmlToAdf` and the markdown readers read sorts one of three ways, never a +silent drop of content: - A container around document content that ADF has no node for unwraps to its children, its own attributes dropped: `
text
` keeps `text`, losing the alignment. @@ -152,7 +153,7 @@ drop of content: `nestedExpand` inside another; an empty one is refused, since `expand` requires content. A `style` attribute is not read at `0.3.0`: the `textColor` and `backgroundColor` it could reach cost more than they buy. -`plainMarkdownToAdf` reads through `markdownToAdf`'s parser, so it takes the same set. +The three markdown readers share one parser, so they take the same set. ## Names stay text @@ -207,33 +208,31 @@ where its reference implementation nests one `
` in another. Identity-bearing nodes carry their ids in attributes; a document is only portable within its site — accepted. -## Plain task ids come from position +## Portable task ids come from position 2026-09-26, spelling 2026-09-29, the maintainer. Goals 7 and 8. Valid while a site rejects a task node with no `localId`. -`plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId` -in the editor's UUID v4 shape, hashed from the whole markdown and the node's order among those it -mints, skipping any id the document holds: the same markdown reads to the same ids every run, -different markdown to different ids. The same markdown pasted twice into one document repeats its -ids: determinism wins over that case. A node the carry restores stays deep-equal (§Unknown nodes ride the carry): its -ids are only skipped. +`portableMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` a `localId` in the +editor's UUID v4 shape, hashed from the whole markdown and the node's order among those it mints: +the same markdown reads to the same ids every run, different markdown to different ids. The same +markdown pasted twice into one document repeats its ids: determinism wins over that case. ## A callout title keeps its link targets 2026-09-29, the maintainer. Goals 6 and 7. Valid while an expand's `title` is a string. -`plainMarkdownToAdf` writes a link in a folded callout's title as its text and its target in +`portableMarkdownToAdf` writes a link in a folded callout's title as its text and its target in parentheses: `> [!faq]- See [x](http://y)` reads to the title `See x (http://y)`. A link whose text is its target, with or without `mailto:`, keeps its text alone: `` titles `http://y`, `` `a@b.c` — three persona readers agreeing, 2026-09-30. -## The plain flavour's spellings +## The portable flavour's spellings 2026-09-14, panels 2026-09-25 and 2026-09-29, the maintainer. Goals 6 and 7. Valid while GitHub's renderer is the one the audience's markdown is read in. -README §Plain markdown's rows come from a survey of GitHub, GitLab, Gitea, Obsidian, Pandoc, +README §Portable markdown's rows come from a survey of GitHub, GitLab, Gitea, Obsidian, Pandoc, MkDocs, Docusaurus, Typora, Joplin, Logseq, Bear, Notion, Azure DevOps and Discord, GitHub's renderer confirming each shape. Reader panels settled `error` as an error panel, the `==` bounds (3 of 3) and a Han, Hangul, kana, Thai, Lao, Khmer or Myanmar character on either side bounding a @@ -400,9 +399,9 @@ handles one cause alike whichever node, attribute or direction raised it. knows which direction it called and parting them after `0.1.0` is MAJOR (2026-09-23). - A non-finite number takes two codes: `unsupported-node-shape` parsing, `not-an-adf-document` emitting — no document holds one, so no round-trip crosses them (2026-09-23). -- `markdownToAdf`, `plainMarkdownToAdf` and `htmlToAdf` refuse a value that is not a string as - `not-a-string`, at position `{ line: 1, offset: 0 }` with an empty path: no existing code reads - true of it, and `not-an-adf-document` names the emitters' input (2026-10-05). +- The markdown readers and `htmlToAdf` refuse a value that is not a string as `not-a-string`, at + position `{ line: 1, offset: 0 }` with an empty path: no existing code reads true of it, and + `not-an-adf-document` names the emitters' input (2026-10-05). ## `message` and `path` @@ -551,15 +550,16 @@ markdown, on a fixed seed in the gate; a counterexample found becomes a round-tr ## The CommonMark suite checks three ways -2026-08-27, the maintainer. Goals 1 and 4. Valid while the suite's answers are HTML ADF cannot be -compared against. +2026-08-27, against `commonMarkToAdf` 2026-10-09, the maintainer. Goals 1 and 4. Valid while the +suite's answers are HTML ADF cannot be compared against. -Each example is a named error or markdown that parses and emits to itself byte for byte; its -reference HTML's text, tags stripped and entities decoded, equals the parsed document's; and its -elements count the marks and nodes they map to. The fixpoint alone passes a parser returning the -empty document, the text alone one dropping every emphasis. An exception is the maintainer's to -add, and valid CommonMark parsing to a document `adfToMarkdown` refuses is a bug to fix, never an -exception. +Each example is a named error or markdown `commonMarkToAdf` reads to a document that +`adfToLosslessMarkdown` writes and `losslessMarkdownToAdf` reads back deep-equal, since CommonMark +has no writer of its own; its reference HTML's text, tags stripped and entities decoded, equals the +parsed document's; and its elements count the marks and nodes they map to. The fixpoint alone +passes a parser returning the empty document, the text alone one dropping every emphasis. An +exception is the maintainer's to add, and valid CommonMark parsing to a document +`adfToLosslessMarkdown` refuses is a bug to fix, never an exception. ## The flavour spec is read as a source @@ -647,7 +647,7 @@ two cannot disagree — which is what makes the kept value a memo rather than a 2026-09-19, the maintainer. Goal 9. Valid while the `commonMarkSpelling` ask spells a node once per level above it otherwise. -The parse and the plain reduction keep each node's readable spelling in a memo, so the +The parse and the portable reduction keep each node's readable spelling in a memo, so the `commonMarkSpelling` ask stops spelling a node once per level above it. `text` and `spelling` carry no depth and `headroom` is affine in it, so a read at or above the depth that filled the entry rebases; a read below re-spells, because a hit skips the depth guards the walk it replaces runs and @@ -720,7 +720,7 @@ them. A primitive knowing neither ADF nor a format stays at `src/` root. A const `spec/flavour.md` draws, and a placement nothing here settles goes beside its only reader, or in what both read where there are two. -A flavour's own code, both directions included, sits in its own directory: `markdown/plain/`; +A flavour's own code, both directions included, sits in its own directory: `markdown/portable/`; `todo.md` item 62 moves what still sits in `parse/` and `emit/`. Otherwise each format directory parts into `emit/` (ADF→format) and `parse/` (format→ADF), the rest of it holding what both directions read. A construct's reader lives there beside the regex the emitter escapes against, so diff --git a/spec/flavour.md b/spec/flavour.md index 83fa506..0d19633 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -1,18 +1,18 @@ # The markdown flavour -The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain -CommonMark is a subset apart from raw HTML (below), with four carve-outs: literal text that matches -directive syntax below or reads as a pipe table is claimed by the flavour, a matched `~~` pair -spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal), and a code fence whose info -string opens `adf:` is the opaque carry (drop the info string and wrap the fence in -`!adf:codeBlock {language="adf:…"}` to keep it code) — and one gap: a CommonMark image fits only as -its own title-less paragraph — mid-text and titled images are named errors. The emitted form is -contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this grammar in the -sections below. +The grammar of the lossless flavour, the extended markdown `adfToLosslessMarkdown` emits and +`losslessMarkdownToAdf` parses. CommonMark is a subset apart from raw HTML (below), with four +carve-outs: literal text that matches directive syntax below or reads as a pipe table is claimed by +the flavour, a matched `~~` pair spells `strike` (escape the `!adf:`, `|` or `~` to keep it +literal), and a code fence whose info string opens `adf:` is the opaque carry (drop the info string +and wrap the fence in `!adf:codeBlock {language="adf:…"}` to keep it code) — and one gap: a +CommonMark image fits only as its own title-less paragraph — mid-text and titled images are named +errors. `commonMarkToAdf` claims none of the four. The emitted form is contract (`docs/decisions.md` +§The formats are API). Per-node syntaxes build on this grammar in the sections below. ## Canonical form -`adfToMarkdown` emits exactly one spelling; every CommonMark variant of the same document +`adfToLosslessMarkdown` emits exactly one spelling; every CommonMark variant of the same document normalizes to it through the round-trip. - Emphasis `_em_`, strong `**strong**`, strike `~~strike~~`; `*` replaces `_` only where `_` @@ -194,27 +194,27 @@ is the fence. ## Raw HTML in input -CommonMark input may contain raw HTML. `markdownToAdf` routes each construct through the foreign -HTML element mapping (`docs/decisions.md` §Foreign HTML sorts three ways) — ADF has no raw-HTML -node, so a construct without a mapping, comments and processing instructions included, is an error -result naming it. The flavour never emits raw HTML. +CommonMark input may contain raw HTML. Every markdown reader routes each construct through the +foreign HTML element mapping (`docs/decisions.md` §Foreign HTML sorts three ways) — ADF has no +raw-HTML node, so a construct without a mapping, comments and processing instructions included, is +an error result naming it. The flavour never emits raw HTML. ## Block nodes The directive name is always the ADF node type. A container's body is the node's `content`; a leaf -has none. Every directive parses in any position — `markdownToAdf` builds exactly what is written; -validity against ADF's content models stays the author's business (`docs/decisions.md` §No schema -validation). It parses only in the form the emitter picks, though: a directive spelling a node the -emitter would have written as CommonMark is a named error. +has none. Every directive parses in any position — `losslessMarkdownToAdf` builds exactly what is +written; validity against ADF's content models stays the author's business (`docs/decisions.md` §No +schema validation). It parses only in the form the emitter picks, though: a directive spelling a +node the emitter would have written as CommonMark is a named error. Each section lists attributes as `name (type)`. A parenthesized value set documents what real payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare (`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys sorted), -quoted, `-0` spelled `-0`. `markdownToAdf` builds an `attrs`, `content` or `marks` key only where the -markdown spells one, an empty one through its reserved key (Attributes), so a document reads back -deep-equal (`docs/decisions.md` §Equality is deep). A node CommonMark spells takes the directive -form to hold an empty key. +quoted, `-0` spelled `-0`. `losslessMarkdownToAdf` builds an `attrs`, `content` or `marks` key only +where the markdown spells one, an empty one through its reserved key (Attributes), so a document +reads back deep-equal (`docs/decisions.md` §Equality is deep). A node CommonMark spells takes the +directive form to hold an empty key. Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a `json` value, `marks=empty` where it is empty: @@ -309,16 +309,15 @@ The moon, at night. !adf:/mediaSingle ``` -**The CommonMark image.** A paragraph whose entire inline content is one image `![alt](url)` is -a `mediaSingle` with attrs exactly `{"layout":"center"}` holding an `external` `media` — `url` -from the destination, `alt` the description's plain-text content when non-empty — a link or image -inside 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. `adfToMarkdown` emits the image form for exactly -that 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. An image amid -other text, or one carrying a title, is a named error: `mediaInline` carries a media +**The CommonMark image.** A paragraph whose entire inline content is one image `![alt](url)` is a +`mediaSingle` with attrs exactly `{"layout":"center"}` holding an `external` `media` — `url` from +the destination, `alt` the description's plain-text content when non-empty — a link or image inside +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. `adfToLosslessMarkdown` emits the image form for exactly that +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. +An image amid other text, or one carrying a title, is a named error: `mediaInline` carries a media `collection` + `id`, never a URL, and no media node carries a title. ### Tables @@ -525,7 +524,7 @@ the directive form, open to no literal reading, is a named error. A spelling adds its mark to every inline node it wraps, and nesting is the marks array in order, outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the -reverse. `adfToMarkdown` nests in the order the array holds rather than sorting it — +reverse. `adfToLosslessMarkdown` nests in the order the array holds rather than sorting it — `docs/decisions.md` §Equality is deep restores the array, not a set — and opens each spelling once over the longest run of adjacent inline nodes carrying an identical mark, attributes included, at that depth: `attrs: {}` differs from no `attrs`, and a directive spells it `{attrs=empty}`. A run diff --git a/todo.md b/todo.md index 19d20c6..a36b7a0 100644 --- a/todo.md +++ b/todo.md @@ -25,13 +25,12 @@ | ID | Release | Exempt | Item | R | S | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---| -| 43 | 0.2.0 | decision | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 4, 5 | 20.3 | | 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 both readers, and retire `!adf:listBreak`.** | 4 | 5 | 5 | 8 | 4, 5 | 15.5 | +| 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 | | 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` to `markdown/flavour.ts`.** | 3 | 5 | 2 | 6 | 2 | 10.4 | -| 62 | 0.2.0 | decision | **Move the plain flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/plain/`, so `emit/` no longer imports `plain/`.** | 4 | 5 | 2 | 6 | 2 | 9.4 | +| 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 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 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 | @@ -55,7 +54,7 @@ | 34 | 0.4.0 | | **Read emphasis flanking by the whole character beside an astral symbol.** | 2 | 3 | 3 | 6 | 4, 5 | 11.3 | | 31 | 0.4.0 | | **Make the branch-coverage figure repeat across runs of an unchanged tree.** | 2 | 3 | 3 | 4 | 1 | 11.2 | | 42 | 0.4.0 | defect | **Trim a text leaf's trailing blanks in linear time.** | 1 | 2 | 5 | 9 | 9 | 10.5 | -| 33 | 0.4.0 | defect | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 9 | 8.7 | +| 33 | 0.4.0 | defect | **Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.** | 4 | 5 | 6 | 9 | 9 | 8.7 | | 56 | 0.4.0 | principle | **Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.** | 4 | 5 | 2 | 4 | 1 | 6.8 | | 76 | 0.4.0 | principle | **State the files the package does not ship once, so the build, the lint ceiling and the coverage exclusions cannot drift apart.** | 2 | 2 | 1 | 3 | 1 | 5.5 | | 82 | 0.4.0 | principle | **Give the directive tests and fixtures random UUID v7 ids in place of `a-1`, `a`, `x` and `h`.** | 1 | 3 | 1 | 2 | 1 | 4.2 | @@ -65,14 +64,6 @@ ## Details -### 43. Give each markdown input its own reader, strict to its own standard. - -Today `markdownToAdf` reads CommonMark and the lossless flavour as one input: text shaped like a -directive, a pipe table or a `~~` pair becomes a flavour node where CommonMark reads plain text, and -a code fence whose info string opens `adf:` becomes the block carry where CommonMark reads code. A -caller names the markdown it hands in: CommonMark, read as its spec says, or the lossless flavour, -read as `spec/flavour.md` says. Breaking: `MIGRATION.md` says which call a caller takes. - ### 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 @@ -80,22 +71,21 @@ Both are refused today with `unmappable-image`: 14 examples in 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, the lossless flavour reads both as -CommonMark does. An image inside a link: `[![moon](moon.jpg)](/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. +title as a `caption` (the maintainer, 2026-10-04). By Goal 5, every reader reads both as CommonMark +does. An image inside a link: `[![moon](moon.jpg)](/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 both readers, and retire `!adf:listBreak`. +### 49. Read a list whose bullet or ordered delimiter changes as two lists, in every reader, and retire `!adf:listBreak`. -Lands after item 43. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads -two (spec examples 301 and 302). Goal 5 settles it for the lossless flavour too: CommonMark spells -adjacent lists by changing the marker, so `adfToMarkdown` and `adfToPlainMarkdown` alternate `-` and -`*` between adjacent bullet lists and `.` and `)` between adjacent ordered lists, and -`!adf:listBreak` retires. `*` is the maintainer's pick (2026-10-04). Breaking, so it ships beside -item 43: `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. +Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads two (spec examples +301 and 302). Goal 5 settles it for the lossless flavour too: CommonMark spells adjacent lists by +changing the marker, so `adfToLosslessMarkdown` and `adfToPortableMarkdown` alternate `-` and `*` +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. ### 61. Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it. @@ -111,28 +101,28 @@ of one character from each of its characters. from where it happens to live. One module naming the questions keeps `docs/decisions.md` §The source parts by ADF and format true as they grow. -### 59. Group the directive grammar into `src/markdown/directive/`, move `Read` to `result.ts`, and move `Flavour` to `markdown/flavour.ts`. +### 59. Group the directive grammar into `src/markdown/directive/`, move `Read` to `result.ts`, and move `Flavour` and `flavourClaims` to `markdown/flavour.ts`. Eight directive files sit across three directories, and the `markdown/` root holds 14 entries. HTML -needs `Read` and `Flavour` out of the plain flavour and `markdown/`; it needs the carry and the -mark spellings too, which item 7 moves where it learns what HTML shares. +needs `Read` and `Flavour` out of the portable flavour and `markdown/`; it needs the carry and +the mark spellings too, which item 7 moves where it learns what HTML shares. -### 62. Move the plain flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/plain/`, so `emit/` no longer imports `plain/`. +### 62. Move the portable flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/portable/`, so `emit/` no longer imports `portable/`. -Reading: the alert and task-marker reads in `parse/markdown-to-adf.ts`, the `mintTaskIds` call and -the `inlineLeaves` use. Writing: `spellPlainBlock`, `quotedUnder`, `tryTaskList` and `taskBlocks` in +Reading: the alert and task-marker reads in `parse/markdown-to-adf.ts` and the `mintTaskIds` call. +Writing: `spellPortableBlock`, `quotedUnder`, `tryTaskList` and `taskBlocks` in `emit/adf-to-markdown.ts`. And `highlightDelimiter` and `highlightFlanking` move out of -`plain/conventions.ts`, which `emit/` imports them from; item 59 moves `Flavour`. Every -comprehension reader on 2026-10-03 named the plain flavour's spread across three directories. +`portable/conventions.ts`, which `emit/` imports them from; item 59 moves `Flavour` and +`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, so it ships beside item 43: `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. +`[](/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 `markdownToHtml` / `htmlToMarkdown` composed through ADF. @@ -150,7 +140,7 @@ replacement. ### 6. Specify the HTML dialect. Element-by-element mapping, the `data-*` fidelity scheme, the opaque-carry form, and the documented -foreign-element set `htmlToAdf` accepts — the set `markdownToAdf` shares (`spec/flavour.md` §Raw +foreign-element set `htmlToAdf` accepts — the set the markdown readers share (`spec/flavour.md` §Raw HTML in input). The set sorts per `docs/decisions.md` §Foreign HTML sorts three ways. ### 77. Read the input once into a plain copy, so an accessor property or a throwing Proxy yields `not-an-adf-document` and every call returns a result. @@ -174,8 +164,8 @@ technical-principles audit, 2026-10-04. ### 83. Refuse a document whose markdown would pass the engine's string limit with a result, never a `RangeError`. The block carry indents its JSON two spaces a level, so one unknown block holding 497 nested arrays -around 540,000 numbers, about 1.1 MB as JSON, spells about 540 million characters, past V8's -2^29 − 24. `serializeCanonicalJson`'s `join` throws, and `adfToMarkdown` throws with it. Any output +around 540,000 numbers, about 1.1 MB as JSON, spells about 540 million characters, past V8's 2^29 − +24. `serializeCanonicalJson`'s `join` throws, and `adfToLosslessMarkdown` throws with it. Any output past the limit does the same. Refuse it with a code from the closed list, and add a fixture. Found by the README-goals audit, 2026-10-08. @@ -248,9 +238,9 @@ A writer panel chose it on 2026-10-03, 5 of 7, over today's `colwidth="[340,420] ### 51. Match a reference label to its definition under Unicode case folding. `link-syntax.ts` normalizes a label with `toLowerCase`, so `[ẞ]` misses its `[SS]` definition (spec -example 540); lowercasing and then uppercasing folds it. Breaking, so it ships beside item 43: -`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. +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 +spellings" bullet, which counts one fewer. ### 53. Put a block's `marks` spelling to the writer panel and adopt its pick. @@ -266,8 +256,8 @@ the place it goes; the maintainer posts them. Found while planning discoverabili ### 38. Spell a lone surrogate in a text node so it survives a UTF-8 encode. -`adfToMarkdown` emits it verbatim, so markdown stored as UTF-8 reads back U+FFFD; attribute values -already escape it. +`adfToLosslessMarkdown` emits it verbatim, so markdown stored as UTF-8 reads back U+FFFD; attribute +values already escape it. ### 74. Keep the release path publishing after npm retires bypass-2FA tokens. @@ -302,16 +292,16 @@ measured one. ### 42. Trim a text leaf's trailing blanks in linear time. -`plain/inline-reduction.ts`'s `leafEdges` finds the trail with an unanchored `/[ \t]*$/`, quadratic -in a run of blanks inside one leaf: a paragraph of `a`, 80 000 spaces, `b` takes 6.5 s in -`adfToPlainMarkdown`. Scan backward, as the expand title's trim does. +`portable/inline-reduction.ts`'s `leafEdges` finds the trail with an unanchored `/[ \t]*$/`, +quadratic in a run of blanks inside one leaf: a paragraph of `a`, 80 000 spaces, `b` takes 6.5 s in +`adfToPortableMarkdown`. Scan backward, as the expand title's trim does. -### 33. Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`. +### 33. Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`. -`adfToMarkdown` spends 23 s on one paragraph of 2000 × `un` plus `**-r**`: each run its flanking -cannot spell re-emits the whole line before riding the carry, quadratic in the runs, and the plain -reduction's `spellableLine` drops one mark per re-emit the same way. An inline node whose -attributes no spelling writes re-emits the line the same way before riding the carry. Make all +`adfToLosslessMarkdown` spends 23 s on one paragraph of 2000 × `un` plus `**-r**`: each run its +flanking cannot spell re-emits the whole line before riding the carry, quadratic in the runs, and +the portable reduction's `spellableLine` drops one mark per re-emit the same way. An inline node +whose attributes no spelling writes re-emits the line the same way before riding the carry. Make all three linear. ### 56. Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes. @@ -321,10 +311,10 @@ mutate it and return `void`; `walk.leaf` alone is written in seven places. `Cont same file shows the shape to follow. `inline-content.ts`'s `Scan` has the same shape: about fifteen functions write `pending`, `pieces`, `deactivatedBefore` and `openingSpellableLink` and return `void`, and `parseInlineContent` reads a flag `scanInline` leaves on it. The same shape recurs in -`mintTaskIds` (`plain/task-ids.ts`), which writes `attrs` on the document `readDocument` built; +`mintTaskIds` (`portable/task-ids.ts`), which writes `attrs` on the document `readDocument` built; `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` (`plain/adf-to-plain-markdown.ts`), which pushes into its caller's array. +output; and `nestIn` (`portable/adf-to-portable-markdown.ts`), which pushes into its caller's array. ### 76. State the files the package does not ship once, so the build, the lint ceiling and the coverage exclusions cannot drift apart. @@ -336,7 +326,7 @@ agree. Found by the technical-principles audit, 2026-10-04. ### 82. Give the directive tests and fixtures random UUID v7 ids in place of `a-1`, `a`, `x` and `h`. -Technical principle: tests use random UUID v7 ids; `plain-markdown-to-adf.test.ts` already does. +Technical principle: tests use random UUID v7 ids; `portable-markdown-to-adf.test.ts` already does. Found by the technical-principles audit of item 78, 2026-10-05. ### 57. Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.