Files
adf-codec/todo.md
T
2026-08-27 16:30:58 +02:00

280 lines
24 KiB
Markdown

# Todo
The plan, in order. Nothing is built. Design questions are settled in `AGENTS.md`; remaining spec
detail is settled at its own milestone.
## Milestones
- [x] **0 — Scaffold.** `package.json` per §6, `tsconfig.json`, `.npmrc` (`save-exact=true`), the
Docker tooling, `renovate.json` (§9), and `.gitea/workflows/ci.yml` gating branches:
`runs-on: docker-host`, actions pinned to semver tags.
- [x] **1a — The directive grammar** (`spec/flavour.md`): inline/block/leaf directive forms,
attributes, escaping, nesting, canonical form, the opaque-carry spelling, the raw-HTML
input policy.
- [x] **1b — Block node syntaxes** in `spec/flavour.md`: panel, expand/nestedExpand, the media
family, the pipe-vs-directive table rule and the directive table form, task and decision
lists, layout, extensions, syncBlock.
- [x] **1c — Inline node syntaxes and marks** in `spec/flavour.md`: mention, emoji, status, date,
inlineCard, mediaInline; underline, subsup, textColor, border; the spelling for text nodes
whose whitespace CommonMark cannot hold (literal newlines, leading or trailing spaces) —
escape-based, never literal, since pipe cells trim and pad. At `mediaInline`, check real
payloads for external-URL support — if it exists, revisit the media section's
mid-text-image error and its "no slot" ground.
- [x] **1d — Corpus start** (§10): checked-in fixtures per spec'd node, in `corpus/`, one
directory per contract kind (`corpus/README.md`).
**Settled** (the maintainer, 2026-08-26): the nodes CommonMark spells get directive sections
of their own, rather than riding the opaque carry. Per `@atlaskit/adf-schema` 57.1.0 every
block node it spells — `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`,
`orderedList`, `paragraph`, `rule` — carries a `localId` with no spelling, `codeBlock` also
`hideLineNumbers`, `uniqueId` and `wrap`, `blockquote` also marks, and `hardBreak` `text`
and `localId`; 2f gives each a place, and the plain spelling stays wherever the attributes
are absent. That hands the two collision sites in `corpus/unspellable/` the second spelling
they lacked, so each takes the directive form as a `media` with an empty `alt` already does
(`spec/flavour.md`, the CommonMark image): a `codeBlock` whose info string is empty, and an
`orderedList` starting at 1.
**Also blocked**: the link rule covers destination spaces only, so two shapes
have no spelling and are refused meanwhile — href `https://example.com/a)b` and title
`He said "hi"`, both in `corpus/unspellable/`. Two defensible spellings each — angle
brackets or a backslash escape, and for titles `'…'` or `(…)` besides — so §8 leaves
the pick here. **Also blocked**: block separation is unstated for a CommonMark block beside a
directive block in a container body — an `expand` whose content is `paragraph` "A" then a
`panel` (`panelType` `warning`) holding "B" spells `A` and `:::panel warning` either on
consecutive lines or with a blank line between. Two defensible spellings, so §8 leaves the
pick here; the answer governs every unknown node type too, the block carry counting as a
CommonMark block since its spelling is a fenced code block.
`unspelled-block-separation` refuses the pair meanwhile, an empty paragraph's
`::paragraph` beside a CommonMark block included — and, since a `mediaSingle`'s spelling now
follows whether CommonMark can spell its URL, two sibling images differing only by an
`&` land in the same refusal.
- [x] **1d1 — The CommonMark subset**: blockquote, bulletList, codeBlock, heading, orderedList,
paragraph, rule, listItem, hardBreak, text, code spans, and the `code`, `em`, `link`,
`strike` and `strong` marks — one mark per text node; nesting is 1d3's.
- [x] **1d2 — Block nodes**: panel, expand/nestedExpand, the media family and the CommonMark
image shape, both table forms, task and decision lists, layout, extensions, syncBlock —
with the reserved `marks` attribute and the fence lengths nesting forces.
- [x] **1d3 — Inline nodes and marks**: date, emoji, inlineCard, mediaInline, mention, status;
border, subsup, textColor, underline; the content slot's `text` attribute and the
`:text{text="…"}` whitespace spelling.
- [x] **2 — `adfToMarkdown`.** First real code. Each sub-item turns one corpus directory green;
the two that have no fixtures yet write them in the same chunk, tests first (§10).
- [x] **2a — The runner and the CommonMark subset.** The corpus runner: walk
`corpus/round-trip/`, assert `adfToMarkdown` emits each `.md` byte for byte. Decide here
where §10's coverage check lives, and gate that every `corpus/**/*.json` re-serializes to
itself under the library's own canonical serializer — one implementation, keys sorted, two
spellings: two-space indent for the corpus files and the block carry's body, compact for
the inline carry. `commonmark-subset/` green.
- [x] **2b — Block nodes.** `block-nodes/` green. A nested list that cannot interrupt the block
above it is refused meanwhile, not spelled: the maintainer's answer on tight-versus-blank
separation turns that refusal into an emission. The test is broader than the name it
carries — `interruptsParagraph` reads the next list alone, so a list after a block no
paragraph continues, a code block say, is refused too — and the same answer narrows it.
Block separation becomes
`separationBetween(previous, next, container)` here — a boolean cannot hold the third case
`spec/flavour.md` states for two directive blocks in a container body, and the maintainer's
answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give
the emitter's refusals a corpus home while the directories grow: `corpus/unspellable/`,
a `.json` beside the `ConvertErrorCode` it must return, the emitter half of `corpus/errors/`.
- [x] **2c — Inline nodes and marks.** `inline-nodes/` green. `InlineSegment` splits into its
two axes — escapability (`backslash`, `bracketed`, `none`) and the emphasis role. A lone
surrogate in a text node emits verbatim and becomes U+FFFD on any UTF-8 encode, a §2 break
plain text still holds open — attribute values already escape it. The pipe form's fallback
reads the emitted segments rather than naming the nodes whose attribute values spell a pipe
as syntax, so 2e's `\u007c` narrows it in one place.
- [x] **2d — The opaque carry** (§3). Fixtures and emitter together, into
`corpus/round-trip/opaque-carry/`: an unknown node in both positions, the reserved `adf`
info string, and the `codeBlock` whose language is `adf` — carried whole ahead of the
attribute fallback 2e owes, since the reservation leaves that node no other spelling
whatever 1d decides for its `localId`.
- [x] **2e — Carve-outs and combinations.** Fixtures and emitter together, into
`corpus/round-trip/combinations/`.
- [x] **2e1 — The carve-outs and the claimed line.** The three carve-outs
and their escapes, and a paragraph line inside a container body shaped like a closing
fence (`:::`, `::: x`). Guard `fenceNestingFault`'s bare-run pop here too — a run shorter
than the open fence is a fault, not a close — which today's emitter cannot reach.
- [x] **2e2 — Mark runs and the runs a carry breaks.** The longest-run rule, attributes
included, and a mark spelling that cannot open where it sits (`un**-real**istic`; the spec
owes the carry a trigger). One mark vocabulary lands here, before 2e3 changes the
attribute spelling: `emphasisSpellings`, `linkAttributes` and the `code`/`link` names join
`inline-directives.ts`, which holds four of the nine marks while the rest are branch
literals in the emitter — and the parser (3) needs every name to make `:em[x]` the named
error `spec/flavour.md` promises.
- [x] **2e3 — Attribute canonicalization and the quoted value's escape.**
**Settled** (the maintainer, 2026-08-26): a quoted attribute value escapes `` ` ``, `&`,
`<` and `|` as `\u0060`, `\u0026`, `\u003c` and `\u007c`, in every directive, block and
inline alike — the constructs those four open all bind at or before a directive does, and
nothing else reaches into `{attrs}`. Emitted attributes being inert leaves 3 free to keep
CommonMark's own precedence between a directive and a code span, and collapsed
`escaping: 'attribute'` into `none`. The escaper's link-opener scan skips emitted syntax
to match: a `](` inside a directive escapes no text `[`.
- [x] **2e4 — The carry's fallback triggers.** `spec/flavour.md` carries a node its section
cannot spell — an attrs key no section lists, a value that is not the section's type, an
arg slot holding no bare token, marks no nesting spells — where the emitter still refuses,
which leaves the refusals a container's own spelling owns. The flanking trigger 2e2
added to that list is the odd one out: `unspellableMark` finds it after assembly and
names a mark type against the line's path, so the failing run needs identifying before
the carry can replace the refusal `mark-inside-word` pinned.
- [x] **2e5 — Combined documents and the collision property.** Documents combining nodes rather
than isolating one, and the gate's collision property: no two corpus documents may emit
the same bytes — one spelling for two documents is a round-trip break no parser can undo,
and it is provable without one.
**Settled** (the maintainer, 2026-08-27): the approximation this item inherited — flanking
exact, CommonMark's *matching* unmodelled — had two round-trip breaks reachable by hand,
so the emitter now models the matching. `process_emphasis` runs over the runs the emitter
wrote (`emphasis-matching.ts`) and a pair it hands to another delimiter rides the carry,
which is what the multiple-of-3 rule did to the em in `un*a**b*****c**istic`. A delimiter
run in text now escapes wherever CommonMark could open or close with it, not only open:
one that could only close stole the spelling around it (`un*a* b*istic`), and escaping
both ways keeps every delimiter the emitter did not write out of the matching. The
canonical form gained a backslash where a run only closes — `\*not emphasis\*`, and
2e1's `carve-out-strike` a third and fourth.
- [x] **2f — The attributes CommonMark cannot hold.** 1d's settled answer: the block nodes
CommonMark spells — `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`,
`orderedList`, `paragraph`, `rule` — get directive sections in `spec/flavour.md` carrying
`localId`, `codeBlock`'s `hideLineNumbers`, `uniqueId` and `wrap`, and `blockquote`'s
marks, while `hardBreak`'s `text` and `localId` join the inline directive it already has.
The plain spelling stays wherever those attributes are absent, so only a node that carries
one takes the directive form — which is what keeps a real payload readable rather than a
page of carried JSON. Fixtures and emitter together, and the three documents the answer
settles leave `corpus/unspellable/` as round-trip pairs: `block-local-id`,
`code-block-empty-language`, `ordered-list-start-one`.
**Settled** (the maintainer, 2026-08-27): the `codeBlock` directive's body is one fenced
code block, the language staying on the fence line so every renderer still highlights it;
a language no info string holds — empty, a backtick, edge whitespace, an entity reference
or the reserved `adf` — rides the `language` attribute with the fence bare, which retires
2d's carry for the reserved name along with the premise that left it no other spelling.
The plain spelling gives way wherever it cannot render what the node carries rather than
only where it has no place for it, so a heading level absent or outside 1-6 and an order
whose markers would run past 999999999 take the directive form too, and
`ambiguous-attribute-spelling`, `unspellable-code-block-language`,
`unspellable-list-marker`, `unspelled-block-marks` and `unsupported-heading-level` leave
`ConvertErrorCode`; content and placement refusals stay, which leaves the directive form
spelling an empty list or a non-`listItem` child that the plain form refuses. `order` is
the first marker, so `order: 1` keeps the plain `1.` — what a real payload carries — and a
list carrying no `order` has no number to take and takes the directive form.
2f raises what 1d's unspelled block separation costs: a single `localId` on a paragraph
beside a plain one now refuses every container body that is a directive's — a panel, an
expand, a table cell — where before 2f the attribute refused the document anyway.
- [ ] **3 — `markdownToAdf`.** The largest single component, so it splits below. Each sub-item
lands the fixtures its own code reads, and the runner grows a parse half as they do:
`parsingDirectories` beside `emittingDirectories`, a round-trip directory joining it only
once every fixture in it reads back to its document, and readers for `corpus/normalization/`
(setext, indented code, loose lists, `*`/`+` bullets, entity references, soft wraps —
one-way, the markdown not canonical) and `corpus/errors/` (a markdown input per named error,
the code in a `.error` beside it) with the first fixture each. The raw-HTML element mapping
is empty until milestone 6, so at `0.1.0` every raw-HTML construct in input — a block, an
inline tag, a comment, a processing instruction — is a named error.
- [ ] **3a — The hierarchy.** Mechanical, ahead of the first parser file: `src/adf/` and
`src/markdown/` (`html/` arrives with its first file, 6-7), the grammar module shared
inside `markdown/`, and `emphasis-matching.ts` beside it — the parser reuses it whole,
`delimiterFlags` and `matchEmphasis` taking CommonMark's own run vocabulary rather than
the emitter's, so no second `process_emphasis` exists to drift from the first. Three files
do not move whole: `block-directives.ts` and `inline-directives.ts` each hold a node table
milestones 6-7 need in `adf/` beside a markdown spelling that belongs in `markdown/`, and
`directive-attributes.ts` fuses the format-neutral conformance walk (`vocabularyPairs`)
with the markdown value spelling HTML has no use for. `spellDestination`, `spellTitle` and
`balanced` leave `markdown-inline.ts` here too — CommonMark destination spelling
`emitLink` and `tryImageLine` share, and the six concerns that file carries are one fewer
for it. `AttributeKind` and `AttributeVocabulary` stay above all of it — the vocabulary a
string-typed attribute grammar needs, which is why HTML will want them too, not a markdown
spelling. Both node tables are a second copy of `spec/flavour.md`'s prose with no drift
guard, and a mistyped attribute name degrades into a false refusal no test catches; 3e
makes them the parser's contract as well, so whether that guard is worth building is
asked here.
- [ ] **3b — The block scanner.** CommonMark block structure, ahead of any inline parsing:
paragraph, ATX and setext heading, thematic break, fenced and indented code block,
blockquote, and bullet and ordered list — continuation, laziness, and the tightness ADF
does not record — with the blank lines between them. Block-level claiming is structure's
too: a colon run or an unescaped leading `|` is claimed here, the parse behind it 3e's and
3f's, a claim with nothing yet to parse it the named error the claim promises meanwhile.
Whether a claimed line interrupts a paragraph is pinned for the plain case and unstated
for the lazy one — a closing fence on the line after a blockquote's open paragraph is lazy
continuation in CommonMark, which would swallow the fence and leave the container
unclosed, and 2e1's `closing-fence-line` orders its blockquote away from that edge
meanwhile — so the answer lands here.
- [ ] **3c — Inline text.** The inline scanner over a block's content: backslash escapes,
entity references decoding to their characters, code spans and the literal they hold —
directive syntax and `~~` included — CommonMark's own hard breaks, a trailing backslash
and two trailing spaces alike, and a soft line break as one space.
- [ ] **3d — Emphasis and links.** `_`, `*` and `~~` runs through `matchEmphasis` to the `em`,
`strong` and `strike` marks; links inline and reference, link reference definitions,
autolinks, and the image gap's named errors — a titled image, and one amid other text.
`spec/flavour.md` does not yet pin `~`'s `can_open`/`can_close`, the CommonMark flanking
the emitter already assumes: it lands here.
- [ ] **3e — The directive grammar.** `commonmark-subset/` reads back. The three forms — inline
`:name[content]{attrs}`, container `:::name arg {attrs}`, leaf `::name arg {attrs}` — with
the attribute grammar and the types each section assigns, the fence-length and nesting
rules, an unknown name as a named error, and the malformed list `spec/flavour.md` spells.
The node tables read backwards turn a parsed directive into its node: the arg to the
attribute it names, the body to `content`, the reserved `marks` key to the marks array.
Precedence in input between a directive and a code span, an entity or raw HTML written raw
inside its attributes is unpinned — 2e3's escape keeps emitted output clear of that
collision, so the pick binds input alone — and it lands here.
- [ ] **3f — The block nodes.** `block-nodes/` reads back: the plain-versus-directive choice 2f
settles, read from the other side; the `codeBlock` directive's fenced body and the
`language` attribute a bare fence leaves it; the media family's composition and the
CommonMark image shape; both table forms, the pipe table's cell split and its named
errors; and the one paragraph an inline body takes.
- [ ] **3g — The inline nodes and the marks.** `inline-nodes/` reads back: the content slot's
`text` attribute and the error a slot holding anything but one unmarked text node is; the
`:text{text="…"}` whitespace spelling, merged into editor-normal text nodes (§2); the
four directive marks and their nesting order, outermost first; and `:em[x]` as the error
`spec/flavour.md` promises.
- [ ] **3h — The carry and the combinations.** `opaque-carry/` and `combinations/` read back:
the `adf` fence and `:adf{json="…"}` restoring a deep-equal node, invalid JSON in either a
named error, a carry inside a mark spelling another, and the three carve-outs' escapes
reading as the literal text they hold.
- [ ] **3i — The CommonMark spec suite.** The suite checked in, pinned to the version it ships,
`corpus/README.md` gaining its kind. What it can assert is the open question: no HTML
exists to compare against until 7, which leaves §2's canonical fixpoint — every example
is either a documented carve-out or gap error, or emits markdown that parses and emits to
itself byte for byte. That exception list is contract, so it is the maintainer's.
`index.ts` gains `markdownToAdf` here, with the README's status line: a parser still
missing syntax is a claim the entrypoint cannot keep, and the corpus reads it from its
module meanwhile.
- [ ] **4 — Round-trip property tests** over the corpus, both ways — the thing that proves 2 and
3. Editor-normal (§2) gets its implementation here — `toEditorNormal(doc)` and the equality
the round-trip asserts, which over normalized input is the canonical serializer's compact
spelling — rather than staying spelled inline as `?? []` at every reader. The reading half is
`nodeContent`/`nodeAttrs`/`nodeMarks` over the ~28 sites spelling it inline today, which also
lifts the branch floor §10 keeps below 100 for exactly those halves.
Generators emit editor-normal ADF (§2). Real sanitized ADF from live Atlassian APIs lands
here too (§10), in `corpus/real-payloads/`: an ADF→markdown→ADF check with no expected
markdown, the payloads supplied by the maintainer. This subsumes 2e5's collision property —
a document that round-trips proves no other document shares its spelling — so decide here
whether that gate stays as the parser-free, faster-failing signal or goes; the half holding
no fixture duplicates is hygiene rather than a round-trip claim, and stays either way.
- [ ] **5 — Release pipeline, ship `0.1.0`.** Publish-on-version-change (§9), `NPM_TOKEN` secret,
the repo made public first (§6). The `ConvertErrorCode` freeze (§8) is checkable here: every
`corpus/unspellable/` document is a decision or a deferred trigger this file names, so the
directory empties as they land and whatever survives is permanent. `0.1.0` is the markdown round-trip: both markdown
directions, the types, `isAdfDocument`. The build lands here: a build tsconfig emitting JS
and `.d.ts` to `dist/` (the dev config's `allowImportingTsExtensions` forces `noEmit`, so
the build config needs `rewriteRelativeImportExtensions`), plus `exports`/`files` in
`package.json`. The maintainer's bump PR also removes `private: true`, the guard against any
earlier publish.
- [ ] **6 — The HTML dialect spec.** Element-by-element mapping, the `data-*` fidelity scheme, the
opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
- [ ] **7 — HTML, ship `0.2.0`.** `adfToHtml`, `htmlToAdf`, the composed `markdownToHtml` /
`htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from here (§10).
- [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists.
## The ADF inventory to cover
From Atlassian's [structure
reference](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/) — not the
whole schema: real payloads also carry `taskList`/`taskItem`, `decisionList`/`decisionItem`,
`layoutSection`/`layoutColumn`, `blockCard`/`embedCard`, `extension`/`bodiedExtension`/`inlineExtension`
and `placeholder`, none documented there. The documented set is the floor: the floor gets designed
syntax, the rest rides the opaque carry (§3) until it does too.
| | |
| --- | --- |
| Top-level block | `blockquote` `bodiedSyncBlock` `bulletList` `codeBlock` `expand` `heading` `mediaGroup` `mediaSingle` `multiBodiedExtension` `orderedList` `panel` `paragraph` `rule` `syncBlock` `table` |
| Child block | `blockTaskItem` `extensionFrame` `listItem` `media` `nestedExpand` `tableCell` `tableHeader` `tableRow` |
| Inline | `date` `emoji` `hardBreak` `inlineCard` `mediaInline` `mention` `status` `text` |
| Marks | `border` `code` `em` `link` `strike` `strong` `subsup` `textColor` `underline` |
Plain markdown covers `blockquote`, `bulletList`, `codeBlock`, `heading`, `orderedList`,
`paragraph`, `rule`, `listItem`, `hardBreak`, `text`, and the `code`, `em`, `link` and `strong`
marks; `strike` is the flavour's `~~` carve-out. Everything else is what the flavour is for.