18 KiB
Todo
The plan. Design questions are settled in AGENTS.md; remaining spec detail is settled at its own
milestone. A done item shrinks to its title here; its full text moves to todo-history.md.
Milestones
Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → 0.1.0 (shipped 2026-09-05); 5e before 2027-01; 3k, 4, 4b, 4c, 5g, 10, 11, 12 → 0.2.0; 4d, 5f → 0.2.1;
6, 7 → 0.3.0; 9 → TBD.
The numbering is the order the work was planned in, not the order it ships.
- 0 — Scaffold.
- 1a — The directive grammar.
- 1b — Block node syntaxes.
- 1c — Inline node syntaxes and marks.
- 1d — Corpus start.
- 1d1 — The CommonMark subset.
- 1d2 — Block nodes.
- 1d3 — Inline nodes and marks.
- 2 —
adfToMarkdown.- 2a — The runner and the CommonMark subset.
- 2b — Block nodes.
- 2c — Inline nodes and marks.
- 2d — The opaque carry.
- 2e — Carve-outs and combinations.
- 2e1 — The carve-outs and the claimed line.
- 2e2 — Mark runs and the runs a carry breaks.
- 2e3 — Attribute canonicalization and the quoted value's escape.
- 2e4 — The carry's fallback triggers.
- 2e5 — Combined documents and the collision property.
- 2f — The attributes CommonMark cannot hold.
- 3 —
markdownToAdf(0.1.0). Each sub-item lands the fixtures its own code reads, and the runner grows a parse half as they do: readers forcorpus/normalization/(setext, indented code, loose lists,*/+bullets, entity references, soft wraps — one-way, the markdown not canonical) andcorpus/errors/(a markdown input per named error, the code in a.errorbeside it) with the first fixture each.commonmark-subset/cannot be the first to green —::paragraphand:hardBreak{}sit in it — so 3b through 3f answer to their own tests and the one-way fixtures they land, and 3g is where the first directory reads back. The raw-HTML element mapping is empty until milestone 6, so at0.1.0every raw-HTML construct in input — a block, an inline tag, a comment, a processing instruction — is a named error. Input is where unbounded nesting actually arrives, so §11's 500 binds all three of the emitter's guards here: block depth at 3c and again at 3f's container fences, inline and mark depth at 3f and 3i, a carried value's JSON at 3j, whereisJsonValuealready bounds it.- 3a — The hierarchy.
- 3b — The leaf blocks.
- 3c — The container blocks.
- 3d — Inline text.
- 3e — Emphasis and links.
- 3f — The directive grammar.
- 3g — The node tables read backwards.
- 3h — The block nodes.
- 3i — The inline nodes and the marks.
- 3j — The carry and the combinations.
- 3k — The CommonMark spec suite (
0.2.0). Checked in atcorpus/commonmark-spec/, pinned to the version it ships — the onecommonmark-grammar.tsnames for its start conditions —corpus/README.mdgaining the kind. Settled (the maintainer, 2026-08-27): three checks an example must pass, the reference HTML each ships read as corpus data — which adds no format and no direction (§1). §2's canonical fixpoint: a named error, or markdown that parses and emits to itself byte for byte. That HTML's text, tags stripped and entities decoded, against the parsed document's concatenatedtext. And a count of the dozen elements the CommonMark subset covers against the marks and nodes they map to — counting distinct mark types per text node, since 3e collapses a spelling nested inside its own kind and*(*a*)*is two<em>against oneem. The fixpoint alone is self-consistency a parser returning the empty document passes, and the text alone one dropping every emphasis; the counts close both. The exception list stays the maintainer's, and one entry is owed already: 3h continues a list across the marker change CommonMark splits on, so an example the reference HTML gives two<ul>counts onebulletList. One outcome is no exception and must not be filed as one: a fixable §2 hole — valid CommonMark parsing to a documentadfToMarkdownrefuses — which is whatcorpus/unspellable/held until 3c, 3e and 3h landed their answers and emptied it. The permanent ones — a link destination or title no escape spells, a paragraph opening with a code span — are the exceptions, named by AGENTS.md §2.
- 4 — Round-trip property tests (
0.2.0), widening 3j's corpus round-trip past the documents a human wrote — the thing that proves 2 and 3 beyond them. Editor-normal (§2) is finished here, on 3i's merging —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 isnodeContent/nodeAttrs/nodeMarksover 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), incorpus/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. - 4b — The block walk's retry (
0.2.0).emitBlockwalks a subtree twice whereverreadableBlockreads it whole and then gives up — a list item whose first line reads back as a thematic break — and the walk below does the same, so the cost doubles per level: 3.4kB of nested lists takes half a second, depth 20 about eight, depth 24 minutes. It predates 3g on both directions, and 3g'scommonMarkSpellinggave it a second entry point. The README's bot and pipeline personas feed markdown nobody typed, so this ships as a hang on a small input; §11's scanning rule is the same argument one shape further in. The retry is what to remove — one walk answering both the readable question and the directive fallback. MemoizingemitBlockis the shortcut, and the node reference is the wrong key: a caller may hold one node object at two positions, where the cached depth and path are another node's.0.1.0ships with the retry in it, so a deep document is slow rather than wrong until the patch.adfDocumentFaultis the second site to look at:isNodeArrayreads every node and attribute value, thennestingFaultreads them again, so the emit entry the export persona runs in bulk walks the document twice. Both walks are linear, so this is a constant factor rather than 4b's class change, and the parting is what gives depth its own code (§8) — measure before joining them back. - 4c — The scanning rule's remaining sites (
0.2.0). A trailing-anchored regex re-walks its run from every start position, so an interior whitespace run costs quadratic time rather than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk replaced the regex. Three sites the same sweep did not reach:normalizeLabelinlink-syntax.ts, whose shortcut-reference input isscan.source.slice(...)rather than the 999-cappedreadLabelvalue, and two inemit/inline-line.ts. The fix is the one 3h used — an index walk,trimTrailingSpacewhere the ends match. A fourth of another shape joins them:readNestedDirectiverestarts its depth counter per level, so each parse level re-scans the region below it and nested inline directives cost O(depth × content) — 3f's cost, which 3i's slot parse doubles rather than changes in class, bounded by the 500-level guard. §11's scanning rule is the whole argument; the pipeline persona feeds documents nobody typed. - 4d — What the gate says while it runs (
0.2.1).ci.shruns nine legs and announces none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from a slow pull — the maintainer hit exactly this on the0.1.0release. Three causes, each its own fix. The legs need markers:plainpages'ci.shprints astep()header per leg and this one prints nothing, so name the leg and the image before each. The longest leg is the quietest:test_output=$(… npm test 2>&1)buffers the whole Node run to replay it after, because the zero-test guard greps the count — stream it and grep a copy (tee), rather than trading the output for the guard. And two legs are silenced outright,npm packand the tarball install, whose>/dev/nullpredates the offline install that made them quick and quiet.publish.showes the same: today it says nothing between readingprivateand the registry answering, which is where itsnpm ciand rebuild sit — the seconds §9 accepts rather than promoting the gate'sdist, and unmeasured until the log shows them. Per-leg timing is what turns "slow or hung" from a guess into a reading; the browser leg's own 5.4–7.9s against a 17s warm gate is the number that made it obviously cheap. - 5 — Ship
0.1.0. - 5e — The publish token's deadline (before 2027-01).
0.1.0published only once the npm token carried Bypass 2FA: the account requiring no 2FA on writes was not enough, and npm answeredEOTPuntil the token itself bypassed. npm retires bypass-2FA tokens for direct publishing around January 2027, and its replacement — trusted publishing over OIDC — supports GitHub Actions, GitLab CI, CircleCI and Buildkite, not Gitea or self-hosted runners. So the release path has an expiry date and no drop-in successor yet. Revisit before the deadline: whether npm has added Gitea or self-hosted OIDC, and otherwise whether the release moves to a human-approved staged publish — which fits badly with publish-on-merge, and is the trade to weigh rather than discover on a red release run. - 5f — Publish the bundle size (
0.2.1). Measure the shipped artifact and put the number in the README, kept honest by the release pipeline rather than by a human re-reading it. The quantity is what a consumer downloads and loads: the tarballnpm packproduces, its unpackeddist, and the built JavaScript minified + gzipped — the figure the competitors advertise (marklassian's "12kb") and the only apple-to-apple one, since ours ships tsc's unminified output and no minifier yet (decide here whether to minify for the build or report the unminified gzip). A publish/pipeline leg measures it and fails when the README figure drifts, so the number can't rot; the figure lands in README §The package beside the "no runtime dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6 kB. - 5g — Reweight the README for the reader (
0.2.0). It opens with the pre-launch rationale — Atlassian's REST APIs,pf-editor-service/convertbeing decommissioned, a link to JRACLOUD-77436 — where a shipped package should answer what it is, what it does and for whom first, then the shortest runnable example; the reader's top seconds go to "why this exists" instead of "what I can do with it". Demote the Jira/endpoint background to a later "why losslessness" note or drop it — the internal references (thejira.atlassian.comURL,pf-editor-service/convert) don't belong in published text at all, no ticket IDs or internal URLs. The0.3.0HTML future should read as an aside, not the lede: the package reads as a shipped0.1.0, not a work-in-progress. - 5a — Rename to
@larvit/adf-codec. - 5b — The consumer's error surface.
- 5b1 — The error's source position.
- 5b2 — The error messages.
- 5b3 — The code list and the flavour's gaps.
- 5b4 — The README's consumer surface.
- 5c — The build and the release pipeline.
- 5d — The browser leg.
- 6 — The HTML dialect spec (
0.3.0). Element-by-element mapping, thedata-*fidelity scheme, the opaque-carry form, and the documented foreign-element sethtmlToAdfaccepts. - 7 — HTML, ship
0.3.0.adfToHtml,htmlToAdf, the composedmarkdownToHtml/htmlToMarkdown. CommonMark spec suite runs againstmarkdownToHtmlfrom here (§10). - 8 — CLI. A later goal, shaped around the personas once the library exists.
- 9 — The online sandbox. A web page with two textboxes converting back and forth between ADF and markdown, powered by the library's browser build.
- 10 — Lossy conversion (
0.2.0). A direction that only converts what Markdown actually supports, keeping the ADF's data while dropping what markdown cannot hold — format, design and the richer nodes. - 11 — Evaluate
@atlaskit/adf-schema(0.2.0). Whether to add@atlaskit/adf-schemaas a dev dependency to use as truth for the ADF schema. - 12 — The
!adf:re-spelling (0.2.0). Replace the colon directive grammar with the namespaced prefix, a breaking change to the emitted contract (shipped0.1.0, so §8 makes it0.2.0). Forms: block container!adf:name arg {attrs}…!adf:/name— the/parts open from close, nestable without a fence-length discipline, so the::::/:::::runs and their length rule go and every container opens the constant!adf:; block leaf!adf:name arg {attrs}with no closer; inline node!adf:name[content]{attrs}; directive marks!adf:border/subsup/textColor/underline[content]{attrs}. Attributes and their escaping stay{key=value}; the literal escape is\!adf:; a line opening!adf:claims as today's colon-run does. Leaf vs container is decided by the node's content model rather than syntax — the::/:::split and §4's name-set-independent recognition go, a simplification the carry makes safe (an unknown block node already rides the fence, not the directive). The carry's reserved name becomescarry, both spellings — the block fence info string`carry`and the inline!adf:carry{json="…"}— named for what it does: it carries a node verbatim, never "unknown-node", since a known node no section spells where it stands rides it too. A spelling change, not a semantic one: noConvertErrorCodeis added, removed or renamed, the round-trip guarantee and the carry both hold through it. Mechanical surface: the grammar inspec/flavour.md,src/adf/block-directives.ts+inline-directives.ts,src/markdown/'sdirective-syntax.ts,opaque-carry.tsand theemit/+parse/readers, every corpus fixture (round-trip, normalization anderrors/),spec.test.ts's prose reader, and the README's examples.- 12a — The spec and the decision. Rewrite
spec/flavour.mdto the!adf:grammar, and record the departures inAGENTS.md§4 (leaf/container by content model, carry renamedcarry). - 12b — The emit side.
adfToMarkdownspells!adf:/!adf:/name/!adf:carry; its fixtures re-spelled, green. - 12c — The parse side and the round-trip.
markdownToAdfreads it back; the round-trip corpus, theerrors/fixtures and the CommonMark spec suite re-spelled,markdownToAdf(adfToMarkdown(doc))still equalsdoc. - 12d — The README and the sweep. The README's examples follow; sweep docs and fixtures
for any stale
::/:namespelling.
- 12a — The spec and the decision. Rewrite
The ADF inventory to cover
From Atlassian's structure
reference — 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.