37 KiB
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
- 0 — Scaffold.
package.jsonper §6,tsconfig.json,.npmrc(save-exact=true), the Docker tooling,renovate.json(§9), and.gitea/workflows/ci.ymlgating branches:runs-on: docker-host, actions pinned to semver tags. - 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. - 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. - 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. AtmediaInline, check real payloads for external-URL support — if it exists, revisit the media section's mid-text-image error and its "no slot" ground. - 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-schema57.1.0 every block node it spells —blockquote,bulletList,codeBlock,heading,listItem,orderedList,paragraph,rule— carries alocalIdwith no spelling,codeBlockalsohideLineNumbers,uniqueIdandwrap,blockquotealso marks, andhardBreaktextandlocalId; 2f gives each a place, and the plain spelling stays wherever the attributes are absent. That hands the two collision sites incorpus/unspellable/the second spelling they lacked, so each takes the directive form as amediawith an emptyaltalready does (spec/flavour.md, the CommonMark image): acodeBlockwhose info string is empty, and anorderedListstarting at 1. Also blocked: the link rule covers destination spaces only, so two shapes have no spelling and are refused meanwhile — hrefhttps://example.com/a)band titleHe said "hi", both incorpus/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 — anexpandwhose content isparagraph"A" then apanel(panelTypewarning) holding "B" spellsAand:::panel warningeither 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-separationrefuses the pair meanwhile, an empty paragraph's::paragraphbeside a CommonMark block included — and, since amediaSingle's spelling now follows whether CommonMark can spell its URL, two sibling images differing only by an&land in the same refusal.- 1d1 — The CommonMark subset: blockquote, bulletList, codeBlock, heading, orderedList,
paragraph, rule, listItem, hardBreak, text, code spans, and the
code,em,link,strikeandstrongmarks — one mark per text node; nesting is 1d3's. - 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
marksattribute and the fence lengths nesting forces. - 1d3 — Inline nodes and marks: date, emoji, inlineCard, mediaInline, mention, status;
border, subsup, textColor, underline; the content slot's
textattribute and the:text{text="…"}whitespace spelling.
- 1d1 — The CommonMark subset: blockquote, bulletList, codeBlock, heading, orderedList,
paragraph, rule, listItem, hardBreak, text, code spans, and the
- 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).- 2a — The runner and the CommonMark subset. The corpus runner: walk
corpus/round-trip/, assertadfToMarkdownemits each.mdbyte for byte. Decide here where §10's coverage check lives, and gate that everycorpus/**/*.jsonre-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. - 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 —interruptsParagraphreads 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 becomesseparationBetween(previous, next, container)here — a boolean cannot hold the third casespec/flavour.mdstates 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.jsonbeside theConvertErrorCodeit must return, the emitter half ofcorpus/errors/. - 2c — Inline nodes and marks.
inline-nodes/green.InlineSegmentsplits 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\u007cnarrows it in one place. - 2d — The opaque carry (§3). Fixtures and emitter together, into
corpus/round-trip/opaque-carry/: an unknown node in both positions, the reservedadfinfo string, and thecodeBlockwhose language isadf— carried whole ahead of the attribute fallback 2e owes, since the reservation leaves that node no other spelling whatever 1d decides for itslocalId. - 2e — Carve-outs and combinations. Fixtures and emitter together, into
corpus/round-trip/combinations/.- 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). GuardfenceNestingFault'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. - 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,linkAttributesand thecode/linknames join the mark table (3a parted it acrossadf/mark-attributes.tsandmarkdown/mark-spellings.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 errorspec/flavour.mdpromises. - 2e3 — Attribute canonicalization and the quoted value's escape.
Settled (the maintainer, 2026-08-26): a quoted attribute value escapes
`,&,<and|as\u0060,\u0026,\u003cand\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 collapsedescaping: 'attribute'intonone. The escaper's link-opener scan skips emitted syntax to match: a](inside a directive escapes no text[. - 2e4 — The carry's fallback triggers.
spec/flavour.mdcarries 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:unspellableMarkfinds 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 refusalmark-inside-wordpinned. - 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_emphasisruns 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 inun*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'scarve-out-strikea third and fourth.
- 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 (
- 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 inspec/flavour.mdcarryinglocalId,codeBlock'shideLineNumbers,uniqueIdandwrap, andblockquote's marks, whilehardBreak'stextandlocalIdjoin 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 leavecorpus/unspellable/as round-trip pairs:block-local-id,code-block-empty-language,ordered-list-start-one. Settled (the maintainer, 2026-08-27): thecodeBlockdirective'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 reservedadf— rides thelanguageattribute 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, andambiguous-attribute-spelling,unspellable-code-block-language,unspellable-list-marker,unspelled-block-marksandunsupported-heading-levelleaveConvertErrorCode; content and placement refusals stay, which leaves the directive form spelling an empty list or a non-listItemchild that the plain form refuses.orderis the first marker, soorder: 1keeps the plain1.— what a real payload carries — and a list carrying noorderhas no number to take and takes the directive form. 2f raises what 1d's unspelled block separation costs: a singlelocalIdon 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.
- 2a — The runner and the CommonMark subset. The corpus runner: walk
- 3 —
markdownToAdf. Each sub-item lands the fixtures its own code reads, and the runner grows a parse half as they do:parsingDirectoriesbesideemittingDirectories, a round-trip directory joining it only once every fixture in it reads back to its document, and 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. Mechanical, ahead of the first parser file:
src/adf/andsrc/markdown/(html/arrives with its first file, 6-7), the grammar module shared insidemarkdown/, andemphasis-matching.tsbeside it — the parser reuses it whole,delimiterFlagsandmatchEmphasistaking CommonMark's own run vocabulary rather than the emitter's, so no secondprocess_emphasisexists to drift from the first.block-directives.tsandinline-directives.tseach part by file, a node table milestones 6-7 need inadf/beside a markdown spelling that belongs inmarkdown/.directive-attributes.tscannot:vocabularyPairswalks the vocabulary and spells the value in one pass, the type check living insidespellAttributeValue, so the check comes out as its own predicate and goes toadf/with the walk while the spelling stays inmarkdown/,isBareTokenwith it — only spelling calls it.markSpellingsis the one table whose keys part rather than its file, so key the markdown half off the ADF half's type: a mark named in one and not the other is then a compile error instead of a false refusal.spellDestination,spellTitleandbalancedleavemarkdown-inline.tshere too — CommonMark destination spellingemitLinkandtryImageLineshare, and the six concerns that file carries are one fewer for it.AttributeKindandAttributeVocabularyfollow the walk intoadf/, the vocabulary a string-typed attribute grammar needs and HTML will want too, not a markdown spelling. Settled (the maintainer, 2026-08-27):markdown/parts here as well, intoemit/andparse/with the shared set at the root — the grammar module, emphasis matching, the tables' markdown halves — andparse/arriving with 3b's first file, the rulehtml/already follows. And the node tables, a second copy ofspec/flavour.md's prose whose mistyped attribute name degrades into a false refusal no test catches, get their guard: a test reads the spec's node sections, takes eachname (type)list and asserts it equals the table, leaving the spec the source a human writes with no build step and no generated file. It is built at 3g, where a wrong entry starts refusing documents. - 3b — The leaf blocks. The line walk that opens and closes a block, ahead of any inline
parsing: paragraph, ATX and setext heading, thematic break, fenced and indented code
block, the HTML block whose lines it swallows whether or not the construct then errors,
the link reference definitions a closing paragraph gives up, and the blank lines between
them. The openers are
commonmark-grammar.ts's — one table answers both directions, or the emitter under-escapes a line the parser reads as a block and §2 breaks in silence — the HTML block's start conditions excepted, which are new here since the emitter writes none. Block-level claiming lands here too: a colon run or an unescaped leading|is claimed, the parse behind it 3f's and 3h's, a claim with nothing yet to parse it the named error the claim promises meanwhile. The runner's parse half comes with it, and the firstnormalization/fixtures, holding inline-trivial content so 3d and 3e add beside them rather than editing them. - 3c — The container blocks. Blockquote, bullet and ordered list: the continuation a
marker's width sets, lazy continuation, and the tightness ADF does not record —
>repeated being two bytes a level, so this is the cheapest way to reach §11's 500. 3b's leaf readers scan the physical line themselves, so a container re-cuts the walk rather than adding to it: the open containers' prefix comes off the line first and the readers take one line at a time,LeafBlockrenamed with the union they join andblockNode's chain gaining their branches. Settled (the maintainer, 2026-08-27): a claimed line ends lazy continuation, so a closing fence on the line after a blockquote's open paragraph closes its container instead of continuing the paragraph CommonMark would fold it into. Claiming at block level is already absolute, and this binds input alone — 2e1'sclosing-fence-lineorders the emitter's blockquote away from the edge either way — sospec/flavour.md's claiming paragraph gains the case here. And 2b's tight-versus-blank, one answer for both directions: the tight spelling stays wherever it parses back, a blank line going in only where the nested list would be swallowed —interruptsParagraphinverted from a refusal into the separation it names, andspec/flavour.md's "none between a nested list and a CommonMark block above it" gaining that exception. Every fixture spelled tight today keeps its bytes, andnested-list-tightbecomes the round-trip pairnested-list-separation. - 3d — 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, a soft line break as one space, the fenced info string's own decoding the block walk leaves raw, and the raw inline tag, comment and processing instruction refused by name, recognized by thecommonmark-grammar.tspredicates the emitter already escapes against, under 3b's one-table rule. Settled (the maintainer, 2026-08-30): entity references decode against HTML5's whole named table, checked in packed (§5) — a curated subset leaves 3k an exception class and a cutoff line nobody can defend. And the escape superset the emitter reads for raw HTML tightens into one precise CommonMark inline reader both directions share, the email autolink parting off as 3e's own predicate: refusing on the superset would refuse1 <b 2, a fourth carve-out §4 and the README do not list.holdsEntityReferencereads the table for the same reason, so¬areference;is emitted bare. - 3e — Emphasis and links.
_,*and~~runs throughmatchEmphasisto theem,strongandstrikemarks; links inline and reference, 3b's definitions resolved here, autolinks, and the image gap's named errors — a titled image, and one amid other text.spec/flavour.mddoes not yet pin~'scan_open/can_close, which is transcription rather than a decision:delimiterFlagsalready gives it CommonMark flanking, as for*, and §8 fixed that the moment the emitter shipped. Settled (the maintainer, 2026-08-27): 1d's deferred pair takes the backslash inside the delimiters it already has —[a](https://example.com/a\)b)and[a](/url "He said \"hi\"").<…>stays reserved for the destination holding a space, where nothing else works, so each construct keeps one spelling and a destination holding both composes.link-destination-parenthesisandlink-title-quotebecome round-trip pairs. Settled (the maintainer, 2026-08-31): the destination escapes only the parenthesis it leaves unbalanced, so/wiki/Foo_(bar)keeps its bytes, and the backslash stays refused in both the destination and the title — which leaves[a](/a\b)a §2 hole 3k's exception list answers, as<http://x?a=1&b=2>is, autolinks decoding neither escapes nor references. The image gap mintsunmappable-image, mirroringunmappable-html— a construct in input no ADF node carries. And the CommonMark image shape lands here rather than at 3h: once[…](…)reads, a lonewould otherwise misparse as text plus a link, so 3h keeps the rest of the media family and loses only that line. Settled (the maintainer, 2026-08-31, on the review): an empty link text —[](/u)— leaves the brackets the text they are rather than minting a refusal or dropping the destination, giving the label back the way an unresolved pair does, so the shortcut behind[][r]still reads. A description holding an image flattens to that image's own alt, which is what alt text means and what keeps the documented gap to mid-text and titled images; a break of either kind inside one reads as a space. And a destination or title whose entity reference decodes to a control character —[a](/x y)— joins 3k's exception list beside the two above: the reader takes cmark's reading, the emitter has no spelling for it. - 3f — The directive grammar. The three forms — inline
:name[content]{attrs}, container:::name arg {attrs}, leaf::name arg {attrs}— the attribute grammar with its quoting and escapes, the fence-length and nesting rules, and the malformed listspec/flavour.mdspells, each a named error.corpus.test.ts'sfenceNestingFaultstays a second reading of the fence rule over emitted bytes: the double entry is the check. Settled (the maintainer, 2026-08-27): the code span, the entity and raw HTML bind first in input, as 2e3 already assumed of the emitted side — a raw`,&,<or|inside{attrs}breaks the directive and is a named error, the author writing the\u0060the emitter writes. One precedence covers both directions, and CommonMark's own ordering stays untouched. Settled (the maintainer, 2026-09-01): a directive whose name reads back to no node takes its own code,unknown-directive-name— a well-formed spelling the vocabulary does not hold is not a malformed one, and §8's "erroring input gaining meaning later is MINOR" is what a consumer switches the two apart for. And input reads canonical spacing only: one space parting the name, the argument,{attrs}and each attribute pair, no padding inside the braces, trailing whitespace on a directive block line tolerated — §8 makes loosening a MINOR, so strict is the reversible direction.directive-attributes.tsbecomesdirective-syntax.tswith the readers in it: the whole directive grammar, both directions, beside the escaping regexes and the spellings it must not drift from. And the 500-level guards compose here for the first time — a recursive reader stacked on the block walk — sonesting-depth-composedpins both axes now rather than waiting for 3i's third. A closing fence closes the innermost open container however long its run, whichspec/flavour.md's closing-fence sentence now says: a run reaching past the innermost leaves the fence it did not close a named error, which §2 prefers to closing more than the author wrote. - 3g — The node tables read backwards.
commonmark-subset/reads back, the first directory to. A parsed directive becomes its node: the name to the type and an unknown one to a named error, the arg to the attribute it names, each value to the type its section assigns, the body tocontent, the reservedmarkskey to the marks array. 3a's drift guard is built here if the answer there was yes. 3f leaves two here:Read<T>moves tosrc/result.tsonce a second reader takes it, and the reservedadfname in block position needs an error of its own — 3f reports it asunknown-directive-name, which §8 makes the signal that a later MINOR may give the name meaning, andadfnever will. Settled (the maintainer, 2026-09-01): the reservedadfname in block position is amalformed-directive— the grammar section states the reservation, so it is that spelling the name breaks — and a well-formed directive the tables refuse isunsupported-node-shape, the emitter's code for the same mismatch read the other way; AGENTS.md §8 carries the split. And input reads canonical{attrs}alone, keys in order and every value spelled as the emitter spells it, the error naming the spelling to write instead: §8 makes loosening a MINOR, so strict is the reversible direction, as 3f already settled for spacing. Settled (the maintainer, 2026-09-01, on the review): 2f's plain-versus-directive choice is read back here rather than at 3h — a directive spelling a node CommonMark holds is refused, so::ruleand:::blockquoteare errors while::rule {localId=…}is not. The parser asksspellsCommonMark, the emitter's own choice, rather than restating the per-node conditions: a copy would refuse the list whose first item reads back as a thematic break, which the emitter does spell as a directive, and §2 breaks in silence. Two refusals land here for a later chunk to lift, on the same rule: the inline[content]slot, which 3i opens foremoji,mentionandstatus, and thecodeBlockcontent model's fenced body, 3h's.Read<T>stays where 3f left it — the node reader knows its path and returnsResult, so no second reader took it. The drift guard earned itself on the way in: the spec'stextattribute was missing from three inline table entries, which the content slot spells and the vocabulary walk already passes over. - 3h — The block nodes.
block-nodes/reads back: thecodeBlockdirective's fenced body and thelanguageattribute a bare fence leaves it; the media family's composition; and both table forms, the pipe table's cell split and its named errors.fenceInfois a rule both directions answer alike and moves to themarkdown/root with the language attribute. Settled (the maintainer, 2026-08-27): 1d's last pick, the onecontainer-block-separationholds — a CommonMark block and a directive block sit adjacent in a container body with no blank line between them. That reduces the three cases to one rule, separation only where its absence would merge the blocks: the:::fence is separation already, and 3c's claim ends the lazy continuation that would otherwise swallow it. The fixture becomes a round-trip pair, and withnested-list-separationand 3e's pair that emptiescorpus/unspellable/: this chunk settles the directory's own guard incorpus.test.tstoo, andunspelled-block-separation, which loses its only cause here. The emitter's other refusals survive on causes no fixture in that directory covers, so 3k's one-list pass is where they get fixtures or the directory goes. Losing that cause closes the last shape input accepts and emit refuses — a CommonMark block beside a directive one inside a list item — so the parse-then-emit fixpoint the README promises holds from here rather than only for what the emitter wrote. - 3i — The inline nodes and the marks.
inline-nodes/reads back: the content slot'stextattribute and the error a slot holding anything but one unmarked text node is; the:text{text="…"}whitespace spelling; the four directive marks and their nesting order, outermost first; and:em[x]as the errorspec/flavour.mdpromises. Editor-normal's merging half lands here,text-whitespacebeing the first fixture that forces it, and 4'stoEditorNormalis built on it. 3g's shape leaves three:readInlineDirectiveNodetakes the name, the attributes and the slot's parsed text rather than the span, sinceinline-content.tsalready imports it and parsing the slot inside it is a cycle; the four directive marks getparse/directive-marks.tsthatinline-content.tstries ahead of the node reader, asmark-spellings.tssits apart fromemit/inline-directive-spelling.ts; and the five markdown-spelled mark names in inline directive position want a claim code —:em[x]is an error forever, sounknown-directive-name's "a later MINOR may give the name meaning" is the wrong signal, as it was foradf.corpus/errors/directive-content-slotgoes when the slot opens. The marks a spelling wraps answer the same question 3g settled for a block's form: only the nesting the emitter writes parses back. - 3j — The carry and the combinations.
opaque-carry/andcombinations/read back: theadffence 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. 3g refuses theadffence rather than reading acodeBlockfrom it; the refusal goes when the carry reads it. - 3k — The CommonMark spec suite. Checked in at
corpus/commonmark-spec/, pinned to the version it ships — the onehtml-blocks.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. One outcome is no exception and must not be filed as one: valid CommonMark parsing to a documentadfToMarkdownrefuses is a §2 hole, which is what three ofcorpus/unspellable/'s four hold until 3c, 3e and 3h land their answers. The parser'sConvertErrorCodeadditions are read here as one list before §8's freeze — nine sessions mint them independently, and one cause wearing two codes is breaking to undo after0.1.0.index.tsgainsmarkdownToAdfhere with the README's status line, neither waiting on the exception list: a parser still missing syntax is a claim the entrypoint cannot keep, and the corpus reads it from its module meanwhile.
- 3a — The hierarchy. Mechanical, ahead of the first parser file:
- 4 — Round-trip property tests over the corpus, both ways — the thing that proves 2 and
3. 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. - 5 — Release pipeline, ship
0.1.0. Publish-on-version-change (§9),NPM_TOKENsecret, the repo made public first (§6). TheConvertErrorCodefreeze (§8) is checkable here: everycorpus/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.0is the markdown round-trip: both markdown directions, the types,isAdfDocument. The build lands here: a build tsconfig emitting JS and.d.tstodist/(the dev config'sallowImportingTsExtensionsforcesnoEmit, so the build config needsrewriteRelativeImportExtensions), plusexports/filesinpackage.json. The maintainer's bump PR also removesprivate: 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 sethtmlToAdfaccepts. - 7 — HTML, ship
0.2.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.
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.