3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Document the three markdown readers: README, MIGRATION, CHANGELOG, the grammar, the decisions and the plan

This commit is contained in:
2026-10-09 02:36:06 +02:00
committed by Lillem4n
parent 23ec45febd
commit 66e4c7e8b5
8 changed files with 235 additions and 224 deletions
+3 -3
View File
@@ -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))`.
+25 -20
View File
@@ -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:<type>` 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
`&#13;` 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 `&#13;` 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
+20 -12
View File
@@ -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 (`[a<https://example.com/>b](/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` |
+61 -52
View File
@@ -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<string>
markdownToAdf(markdown: string): Result<AdfDocument, ParseError>
adfToLosslessMarkdown(doc: AdfDocument): Result<string>
losslessMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
isAdfDocument(v: unknown): v is AdfDocument
adfToPlainMarkdown(doc: AdfDocument): Result<string>
plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
commonMarkToAdf(markdown: string): Result<AdfDocument, ParseError>
adfToPortableMarkdown(doc: AdfDocument): Result<string>
portableMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
adfToHtml(doc: AdfDocument): Result<string> // 0.3.0
htmlToAdf(html: string): Result<AdfDocument, ParseError> // 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 — `&#10;`, `&#13;`, `!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 `<a>` 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
+9 -9
View File
@@ -2,21 +2,21 @@
One directory per contract kind:
- `round-trip/` — `<name>.json` + `<name>.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/` — `<name>.json` + `<name>.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/` — `<name>.md` + `<name>.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/` — `<name>.md`: markdown input that must not convert. A `<name>.error` beside it
pins which error.
- `real-payloads/` — `<name>.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
+37 -37
View File
@@ -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: `<div align="center">text</div>` 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 `<a>` 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: `<http://y>` titles `http://y`,
`<a@b.c>` `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
+32 -33
View File
@@ -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
+48 -58
View File
@@ -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<T>` 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<T>` 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<AdfDocument>`.** | 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<T>` to `result.ts`, and move `Flavour` to `markdown/flavour.ts`.
### 59. Group the directive grammar into `src/markdown/directive/`, move `Read<T>` 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<T>` 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<T>` 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.