25 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); 3k, 11, 4, 12, 13, 4b, 4c, 10, 5g → 0.2.0; 4d, 5f → 0.2.1;
6, 7 → 0.3.0; 9 → TBD; 5e last.
The numbering is the order the work was planned in, not the order it ships. 0.2.0's order is settled
(the maintainer, 2026-09-13): 11 makes the tables 4 generates from answer to Atlassian's schema, 4
proves 12, 13 spells 11's gaps in 12's grammar, and 12 rewrites code 4b and 4c change.
- 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.- 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.
- 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. Settled (the maintainer, 2026-09-13):fast-checkgenerates and shrinks. The gate runs a fixed seed, the properties together adding about five seconds per engine; an environment variable raises the runs and randomizes the seed for local digging, and a counterexample found becomes a round-trip fixture. The generators draw from the node tables — each node's content model and attribute vocabulary asadf/records them, which 11 holds to Atlassian's schema — and misplace a share of nodes so the carry (§3) is exercised; no JSON Schema walker enters the tests.toEditorNormalstays internal. 2e5's collision test goes, since a collision already fails the round-trip on the same fixtures; the fixture-duplicate test stays.- 4.1 — Editor-normal and the node accessors.
toEditorNormal(doc)insrc/adf/editor-normal.ts, on 3i's merging: adjacent text nodes carrying identical marks merged, an emptyattrs,marksorcontentthe absent key (§2), and the round-trip tests compare through it.nodeContent/nodeAttrs/nodeMarksreplace the 49 inline?? []/?? {}reads insrc/(27content, 12marks, 10attrs), and the branch floor rises to what the suite then measures. - 4.2 — The ADF property.
fast-checkjoinsdevDependencies, AGENTS.md §5 naming what it earns — shrinking a failing document to the nodes that break it — and §10 the properties beside the corpus. A generated editor-normal document either refuses inadfToMarkdownwith aConvertErroror reads back throughmarkdownToAdfto an equal document, and nothing throws, under Node, Deno and Bun alike. 2e5's collision test is deleted. - 4.3 — The markdown property. Generated markdown through
markdownToAdfnever throws, and the runs fit the budget; where it parses andadfToMarkdownspells the result, that spelling parses and emits to itself byte for byte (§2). - 4.4 — The real payloads.
corpus/real-payloads/holds the maintainer's sanitized payloads, each round-tripped ADF→markdown→ADF with no expected markdown. It waits on the maintainer placing the files.
- 4.1 — Editor-normal and the node accessors.
- 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.
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, leaving themnpm stage publish, which a maintainer approves with 2FA; its replacement — trusted publishing over OIDC — supports GitHub-hosted Actions, GitLab.com's shared runners and CircleCI's cloud, self-hosted runners planned without a date. So the release path has an expiry date and no drop-in successor yet. Revisit: whether npm has added Gitea or self-hosted OIDC, and otherwise whether the release moves to the staged publish — which fits badly with publish-on-merge, and is the trade to weigh rather than discover on a red release run. Settled (the maintainer, 2026-09-13): last of the known work, clear of0.2.0, placed there knowing the cutoff may land before0.2.0ships. - 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. Settled (the maintainer, 2026-09-13): the background goes entirely, no endpoint, ticket or "why" note left. The top follows the package-README order: an npm version badge and the Gitea Actions badge, a tagline that is alsopackage.json'sdescription, a feature list and a one-line table of contents, then install and the shortest runnable example; a table of everything exported sits near the bottom. The HTML directions are one aside line under the API until0.3.0ships them, the// 0.3.0signatures and the0.3.0guarantee going until then. The tagline anddescriptionread "Lossless conversion between Atlassian Document Format and extended markdown" until 7 restores HTML. - 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). The README's tagline andpackage.json'sdescriptionregain HTML (5g). - 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. Settled (the maintainer, 2026-09-13):adfToPlainMarkdown(doc)is one export whose body reduces the document ADF→ADF insrc/adf/and hands the result toadfToMarkdown, so §1's four conversions stay four. Its markdown is the flavour without directives — CommonMark, the pipe table and~~— and it refuses only what the document guard refuses (not-an-adf-document,unsupported-document-version,unsupported-nesting-depth); every other shape degrades. The reduction: -panel,layoutSection/layoutColumn,bodiedExtension,bodiedSyncBlock,multiBodiedExtensionandextensionFrameunwrap to their body blocks in order;expandandnestedExpandput their title first as a strong paragraph. - The CommonMark blocks keep their spelling, attributes dropped. -taskListanddecisionListbecome bullet lists, a task item's state leading its text as[x]or[ ], the way Obsidian and GFM write a checkbox:- [x] Write the spec. -mentionandstatusbecome their text,emojiits text or else itsshortName, anddateits ISO date in UTC (2026-09-13). -inlineCard,blockCardandembedCardbecome a link to theirurl, dropped when they carry onlydata; amediaSingleholding an external image stays;media,mediaGroupandmediaInlinebecome theiralttext or nothing;captionits text as a paragraph;extension,inlineExtensionandsyncBlocktheirtextattribute or nothing;placeholdernothing; a node no row names, or one standing where no spelling holds it, its blocks or its text. - A table stays a pipe table: the first row becomes the header, a cell's blocks join on one line with spaces, and spans and the cells they cover drop. -code,em,link,strikeandstrongstay and every other mark drops, keeping its text; a link no CommonMark escape writes becomes its text, and a mark run CommonMark's flanking or matching cannot spell drops its mark. - A newline in text becomes a hard break and edge whitespace is trimmed; carriage returns and null characters are removed; a paragraph line opening with a code span whose backticks would read as a fence loses the code mark; an empty paragraph drops, and adjacent lists of one type merge.- 10a — Obsidian's formats. Look up the formats Obsidian-flavoured markdown adds — callouts, highlights, embeds, task states and whatever else it writes — and propose which of the reduction's rows should adopt one; the maintainer settles the proposal, revising the rows above, before 10b starts (the maintainer's request, 2026-09-13).
- 10b — The reduction. The reduction in
src/adf/, tests first, a test per row as 10a leaves them. - 10c —
adfToPlainMarkdown. The export and its README section, and a property over 4.2's generators: it refuses only the guard's codes, and its output reads back throughmarkdownToAdfholding no node or mark the flavour spells as a directive. AGENTS.md §1 records the reduction as what keeps the conversions at four.
- 11 — Atlassian's ADF schema as the tables' truth (
0.2.0).@atlaskit/adf-schema's two JSON Schemas vendored rather than the package installed (AGENTS.md §5), and the node tables gated against them (§10). Settled (the maintainer, 2026-09-13): vendored atspec/adf-schema/and re-pinned by hand when a need shows; the gate compares attribute names and kinds, never value sets, overfull.jsonandstage-0.jsontogether.- 11a — The vendored schema.
- 11b — The gate. For each node and mark type the tables spell, the attribute names and
kinds equal the union over every definition in both files whose
typeenum names it,anyOf/allOfbranches included, the argument slot (panelType,state) counting as spelled. Kinds:string;number,integerincluded;boolean;jsonfor an object, an array or an untyped value; anenum-only attribute takes its values' kind. What the schema holds past the tables is pinned in two exact lists — an entry the schema no longer needs is red, like a difference neither list names: gaps, attributes of a spelled type (57.4.9:linkcollectionidoccurrenceKey,rulecolorstyleweight,layoutSectioncolumnRuleStyle), emptied by 13; and carried, types the tables do not spell (alignmentannotationbackgroundColorblockCardbodiedRulebreakoutdataConsumerembedCardfontSizefragmentindentationinlineExtensionplaceholder),docandtextcounting as the grammar's own.
- 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:. Leaf vs container is decided by the node's content model rather than syntax — the::/:::split goes, 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 stringcarryand 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. NoConvertErrorCodeis added, removed or renamed, and 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/), the prose reader overspec/flavour.md, and the README's examples. Settled (the maintainer, 2026-09-13): - A line opening!adf:nameis a block line when a space or the line's end follows the name, and a paragraph when[or{does. Claiming stays syntactic and structure comes from the tables: an unknown name isunknown-directive-nameat the opener, whatever follows it. - An unescaped!adf:claims on its own anywhere inline: one completing no directive ismalformed-directive, the emitter escapes every literal!adf:, and!adf:hardBreak{}keeps its braces. Block and inline share the one\!adf:escape hint. - A closer names the innermost open container, crosses no list-item or blockquote edge, indents as a fence does and carries nothing after the name; anything else ismalformed-directive. - A node holding no content whose content model takes some is an empty opener–closer pair, never a leaf. - A spelled node's content model is frozen with its spelling: changing it is MAJOR (§8). - The colon spellings are dropped, not refused:0.1.0markdown reads back as prose,adfis no longer a reserved language, andMIGRATION.mdtells a consumer to convert stored markdown through0.1.0's parser and0.2.0's emitter. - Inputs moving between codes ride the break: a leaf given a body, a container missing its closer andlistBreakwith a body aremalformed-directive, and an empty inline-body container parses. - Split by construct, each sub-item both directions: 55 of 78 round-trip fixtures feed both the emit and the read-back test, so an emit-only chunk cannot land green.- 12a — The spec and the decision.
spec/flavour.mdrewritten to the!adf:grammar and the settled answers above, no colon directive form left in it; AGENTS.md §4's directive bullet and prior-art line, and §8's escape hints and::adf/::listBreakexamples, name the new forms, §8 gaining the frozen content model. - 12b — The inline form. Inline nodes, directive marks,
textand the inline carry!adf:carry{json=…}spelled and read as!adf:name[content]{attrs}, with the prefix claim and its escape; the round-trip, normalization anderrors/fixtures holding inline forms re-spelled, and the gate green. - 12c — The block form. Openers and
!adf:/nameclosers, leaf vs container by content model, empty pairs,listBreakand thecarryfence, spelled and read; the fence-length rule and the corpus test's fence nesting check deleted; the remaining fixtures re-spelled anderrors/re-derived under the shifted codes, and the gate green. - 12d — The README,
MIGRATION.mdand the sweep. The README's examples and error tables follow,MIGRATION.mdlinked from one README line; docs and fixtures swept for any stale::/:namespelling.
- 12a — The spec and the decision.
- 13 — The schema's gap attributes (
0.2.0). Spell the attributes 11b pins as gaps, in 12's grammar, and empty the list. Settled (the maintainer, 2026-09-13): a link[text](url "title")cannot hold takes the directive mark!adf:link[text]{attrs}— one carryingcollection,idoroccurrenceKey, or anhrefortitleno CommonMark escape writes — and a directive link CommonMark could spell isunsupported-node-shape. That leavesunspellable-linkno cause, so it leavesConvertErrorCodein0.2.0, §8 recording the removal.- 13a —
ruleandlayoutSection.rule'scolor,styleandweightandlayoutSection'scolumnRuleStylejoin their tables andspec/flavour.mdbullets, with round-trip fixtures; their gap entries go. - 13b — The directive link.
linkspelled as above in both directions, with a round-trip fixture per trigger,spec/flavour.md's Marks section following;unspellable-linkremoved from the code list, itserrors/fixtures and the CommonMark suite'sunspellableexceptions it cures re-derived, and the README's code table and its "not every document converts back" guarantee following; the gap list is empty.
- 13a —
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.