adfast converts Markdown to and from ADF at the AST level. ADF (Atlassian Document Format) is the JSON document model behind Jira Cloud and Confluence Cloud. The output is round-trip-stable and remark-compatible. A typed ADF document model keeps everything that adfast does not understand, and it keeps it losslessly.
go get github.com/pmarschik/adfast
go get github.com/pmarschik/adfast/jira # Jira link conventions
go get github.com/pmarschik/adfast/confluence # Confluence page links + code macro languages
go get github.com/pmarschik/adfast/skill # the dialect as an embeddable agent skill
go get github.com/pmarschik/adfast/frontmatter # optional YAML frontmatter parse/render/patchadfast needs Go 1.27 or later. jira/,
confluence/, skill/, and
frontmatter/ are separate Go modules. A
product-specific addon ships as a submodule, so a consumer pulls only
what it uses.
wasm/ is a separate module too, but it is a build artifact
rather than a library. It compiles the conversion and the directive
dialect to WebAssembly for a JavaScript consumer, such as an editor
integration that must locate and convert directives without a second
parser in TypeScript. Build it instead of a go get:
mise run wasm:build # writes wasm/adfast.wasm; ship it with wasm/wasm_exec.jsRead wasm/README.md for the JS surface, and for the
offsets contract. That contract is the one thing a consumer must not get
wrong.
md := `# Rooftop apiary — season plan
First inspection :date[2026-04-12] — feed status :status[Done]{color="green"}.`
node := adfast.FromMarkdown(md) // parse to the pivot AST (ast.Node)
doc := adfast.ToADF(node) // encode: typed ADF document (adf.Doc)
wire, _ := json.Marshal(doc) // wire-format ADF JSON, ready for the REST API
back := adfast.FromADF(doc) // decode ADF to the pivot AST
out := adfast.ToMarkdown(back) // render back to markdown
// The prettier md → md formatter is a composition (same opts to both):
pretty := adfast.ToMarkdown(
adfast.FromMarkdown(md, adfast.WithPrettierFormat()),
adfast.WithPrettierFormat(),
)md → adf is ToADF(FromMarkdown(md)), and adf → md is
ToMarkdown(FromADF(doc)). The four primitives meet at the pivot AST
(ast.Node), and each one reads the subset of the shared adfast.Option
that it needs. The reverse edge into FromADF is adf.DecodeDoc. It
turns any JSON-decoded ADF value into the typed adf.Doc. Runnable
examples live in example_test.go and on
pkg.go.dev.
- Dialect: CommonMark and GFM, plus remark-directive-style directives through goldmark-directive. The directives carry the ADF features that have no native syntax. Read Supported Markdown.
- remark-compatible rendering: the escaping, the list marker alternation, the prose wrapping, and the character-reference encoding are measured against remark-stringify. They byte-match it on the covered corpus.
- Round-trip stable: the md → adf → md round trip
(
ToMarkdown(FromADF(ToADF(FromMarkdown(md))))) is idempotent. A continuously grown fuzz corpus (FuzzRoundTripIdempotent) enforces this. - Formatter: the prettier md → md formatter is the composition
ToMarkdown(FromMarkdown(md, WithPrettierFormat()), WithPrettierFormat()). It is a pure md → ast → md pass with prettier-compatible output, and it never routes through ADF. Tests enforce its semantic coherence with the ADF conversion. - Extensible: a custom node kind plugs into all four pipeline paths through one public contract. Read Extending adfast.
Both conversion directions pivot through one source-independent tree, the adfast AST. Its semantics mirror the mdast of remark.
markdown text ──goldmark──▶ goldmark AST (source-anchored parse tree)
│ lift: decode escapes, resolve spans,
│ drop source anchoring
▼
adfast AST (shared pivot, remark semantics)
│▲
▼│
ADF tree (Doc) ──json──▶ serialized ADF
The pivot is built once from the goldmark parse, and that build normalizes
the quirks of the parser. It is also what the remark-compatible renderer
consumes. The ADF Doc and Node types are the AST of the ADF side, and
JSON happens only at the very edge.
The facade is FOUR primitives with the pivot AST (ast.Node) as the
explicit currency. Each one is named by its non-AST end, and From* and
To* are inverses at the AST boundary. One shared adfast.Option type
serves them all, and each primitive reads the subset that it needs:
| Primitive | Shape | Role |
|---|---|---|
adfast.FromMarkdown(md, ...Option) |
md → ast.Node |
parse (faithful pivot AST) |
adfast.ToADF(n, ...Option) |
ast.Node → adf.Doc |
encode |
adfast.FromADF(doc, ...Option) |
adf.Doc → ast.Node |
decode |
adfast.ToMarkdown(n, ...Option) |
ast.Node → md |
render |
The common conversions are compositions. Pass the same options to both halves, because each primitive ignores what it does not read:
| Conversion | Composition |
|---|---|
| md → adf | ToADF(FromMarkdown(md)) |
| adf → md | ToMarkdown(FromADF(doc)) |
| md → md | ToMarkdown(FromMarkdown(md, WithPrettierFormat()), WithPrettierFormat()) |
FromMarkdown parses to the faithful pivot AST and stops. It produces no
ADF and no canonicalization, so it is the currency that the To*
primitives consume. Before it parses anything it clears the two decoding
artifacts that are not Markdown: line endings normalize to \n, and a
leading UTF-8 byte order mark is peeled onto ast.Root.ByteOrderMark
(left in the source it is ordinary text on line 1 and the whole first
block misparses). ToMarkdown prepends the mark again from that flag, so
a document that opened with one still opens with one and a document that
did not still does not — adfast never adds or drops an encoding preamble
of its own. A FrontmatterProvider therefore also sees a source with no
mark in it, and needs no tolerance for one. The subpackage functions
markdown.Parse/markdown.Render and convert.ToADF/convert.FromADF
sit one layer down under the same shapes. The To* primitives normalize
on the way out. ToADF encodes through the canonicalizing projection onto
the data model of ADF, and the prettier-format mode of ToMarkdown
(WithPrettierFormat) runs the shared canonicalization pass before it
renders, through its format-leg entry point convert.NormalizeFormat.
The prettier md → md formatter is therefore the composition
ToMarkdown(FromMarkdown(md, WithPrettierFormat()), WithPrettierFormat()).
For a custom width, add WithPrintWidth(w) to both calls. FromMarkdown
is a single faithful parse in both directions. Text values are fully
decoded, because that is the ADF currency, and the literal escapes of
prettier ride separately on ast.Text.Raw as escape provenance. The
format therefore re-emits them byte-for-byte without a change to the
semantic value. Escaping is a render-only concern, and
WithPrettierFormat now has NO parse-side effect at all. Both directions
share one FrontmatterProvider, so frontmatter detection cannot diverge,
and the flag is read on the render call only. The formatter is a pure
md → ast → md pass. It parses to the pivot AST, applies the
convert.NormalizeFormat canonicalization, and renders back with the text
rules of prettier. That canonicalization resolves ::colwidths and
::decisions, canonicalizes the inline marks, and re-derives the
canonical payload of media. It is total: every node that goes in
comes back out, so an unknown directive survives the format verbatim
even though the ADF encode has no node for it (the encode-side
convert.Normalize is the one that degrades it). Nothing routes through
ADF, so the formatter never loses a construct that ADF cannot model.
Frontmatter, raw HTML, and inline images pass straight through.
WithASTTransforms is its content-rewrite seam. Two test obligations
keep the format and the conversion from a drift apart, in place of a
structural guarantee (read format_contract_test.go). The first is
semantic coherence: a format followed by a parse produces the same ADF as
a parse of the original. The second is idempotence. Both run over the
fixture corpus, and both are fuzzed continuously
(FuzzFormatSemanticsPreserved).
One function sits beside the four primitives without being one of them.
adfast.PlainTextOf(md, ...Option) projects a Markdown source down to the
text a reader sees: markup drops, text stays, a code block contributes its
code, and every directive contributes the literal :name the author typed
followed by its label. It is a one-way projection, not a conversion, and
it is not composable from the primitives. FromMarkdown promotes a known
directive name into its typed dialect kind, and that promotion is not
invertible, because :::info, :::note, :::warning, :::success, and
:::error all land on dialect.Panel. A walk over the promoted tree
therefore cannot tell which name was written, and an accidental intraword
colon such as deploy:status would vanish from the middle of a sentence.
PlainTextOf parses with markdown.WithGenericDirectives, which skips
the promotion step whole, and reads the name off the generic node. For an
inline slice that is already parsed, ast.PlainText is the direct form.
Canonical ToADF(FromMarkdown(md)) output is wire-safe unless
WithPreserveListTightness is enabled, or the source carries a {#id}
heading anchor, or a table whose delimiter row carries alignment colons.
Each of these three writes a synthetic attribute, and ADF has no wire form
for it. Run adf.IsWireSafe as the guard
before you submit a document of uncertain origin, and use
adf.StripSynthetic as the matching cleanup.
For a heading anchor, the product bundles are the better answer.
confluence.MarkdownOptions lowers the anchor to the anchor macro of
Confluence. jira.MarkdownOptions drops the anchor and reports a
heading-anchor-dropped diagnostic. For table alignment, both bundles
install adf.LowerTableAlign, which gives every alignable block of an
aligned column the ADF alignment mark. adf.StripSynthetic clears the
attribute for a document that no bundle touches.
Media and attachment resolution is pluggable through WithMediaAssets,
WithAssetIDResolver, and WithImageDimsResolver. If the collection of
downloaded files is large, or an entry costs something to produce,
WithMediaAssetResolver answers the same question one media id at a
time. The conversion then asks only about the media that it meets.
ADF records no ordered-list marker style, so FromADF renders the
reference form: the start number repeated on every item, which matches
remark-stringify with incrementListMarker off. Add
WithIncrementListMarkers where people write and read the Markdown. The
items renumber 1. 2. 3., and a list that a document already spelled
that way survives the round trip unchanged.
Automatic link handling makes no assumptions about the host product.
WithSmartLinks(convert.SmartLinks{KeyFromURL, URLForKey}) teaches the
conversion a URL scheme once for both directions. A link whose text
equals the derived key encodes as an inlineCard, a bare
::linkCard[KEY] label expands, and a card renders back to the short
key. WithLinkResolver(convert.LinkResolver{Encode, Decode}) rewrites
the destination of an ordinary labelled link at the ADF boundary.
Encode maps a Markdown href to its product-facing form, and Decode
restores the stable Markdown href. A resolver miss keeps the original
destination, and cards and media are unaffected.
WithFileCards(convert.FileCards{Card, Link}) publishes a labelled link
as the inline file card the host editor writes for an attached file.
Card answers with the media id of the attachment, and with the
collection it hangs off. Link reads a card back as the link it stands
for, so the round trip returns the Markdown unchanged. The card resolver
sees the href that LinkResolver.Encode produced, and Link gives its
href to LinkResolver.Decode. One link becomes one card, however many
nodes its label was split across. A card holds no label, so a label of
Link stands in, and then the alt text of the card, and then the last
segment of the href. A resolver miss keeps the link, and a card the
resolver does not know stays a ::media directive.
WithDocTransforms
hooks document-level rewrites on the encode side. WithADFTransforms is
the decode-side mirror of this option. Both exist for the
product-specific shapes that a per-node hook cannot reach. Such a shape
moves content between a node and the attributes of the parent, as
confluence.LowerAnchors and confluence.LiftAnchors do, or between a
table and the blocks of its cells, as adf.LowerTableAlign and
adf.LiftTableAlign do.
The jira/ submodule bundles the
Jira conventions. jira.MarkdownOptions and jira.RenderOptions each
return a []adfast.Option slice. Pass the encode-side bundle to both
halves of the md → adf composition, and the decode-side bundle to both
halves of the adf → md one:
mdOpts := jira.MarkdownOptions(baseURL, jira.ExpandAuto)
doc := adfast.ToADF(adfast.FromMarkdown(md, mdOpts...), mdOpts...)
rOpts := jira.RenderOptions()
out := adfast.ToMarkdown(adfast.FromADF(doc, rOpts...), rOpts...)The typed jira.ExpandMode constants select the bare-key expansion:
ExpandAuto, ExpandAll, and ExpandExplicit. The submodule also ships
jira.EncodeRichText with the typed jira.RichTextFormat constants
RichTextADF and RichTextText, where InferRichTextFormat matches
whatever an existing field holds. jira.CodeLanguages is the code-block
language set of the Jira Cloud editor, for the WithCodeLanguages check.
It is exactly adfast.AtlaskitCodeLanguages, the root module's shared
@atlaskit editor language list, which both product submodules derive
from. jira.CodeLanguageAliases (and confluence.CodeLanguageAliases)
is the companion alias → canonical map for
WithCanonicalCodeLanguages; see
Canonical code-block languages.
The confluence/ submodule bundles the Confluence
conventions the same way. confluence.MarkdownOptions(baseURL) and
confluence.RenderOptions() wire smart links for the page URLs of
Confluence Cloud: …/wiki/spaces/KEY/pages/123456789/Title ⇄ the stable
KEY/123456789 key. The mutable title slug is deliberately not part of
the key. The submodule also ships confluence.CodeLanguages: the same
@atlaskit editor language set as Jira's (Confluence Cloud's ADF code
snippet element uses the same picker — measured against
ixolit.atlassian.net page 1190100993 on 2026-08-25), plus the two legacy
code block macro spellings (html/xml, vb) the atlaskit list does not
carry.
confluence.RepairReadBack(doc, storage) repairs what a page read
loses. Confluence converts a page to ADF from its own storage format,
and that conversion drops the code mark on link text and the title
slug of an internal page link. The storage body of the same page version
holds both, so the repair reads it as the oracle. Call it on the document
that the read returned, before FromADF. A comparison between the local
document and the page it published then reports no difference that nobody
made. docs/design.md holds the measurements.
Some pages carry a legacy-content extension: Confluence's own wrapper
for a blockquote or a table nested inside a list item, a shape ADF's
content model forbids. The page still renders — the wrapper carries the
original storage HTML — but reading it back as ADF used to hand you the
wrapper itself, a screenful of escaped HTML and JSON through the generic
::extension directive. confluence.RenderOptions() installs
confluence.ExpandLegacyContent, which replaces the wrapper with the
ADF content its nestedContent parameter carries, in the position the
wrapper held. It is also available standalone
(adfast.WithADFTransforms(confluence.ExpandLegacyContent)) for a
caller composing options by hand. docs/design.md holds the details.
Both bundles also install confluence.Macros(), named directives for the
core Confluence macros. The generic ::extension{key type parameters}
directive can express each one, but the parameters attribute carries a
JSON blob that nobody wants to hand-write. The macros that people use
therefore get a directive of their own:
| Directive | Macro key | Notes |
|---|---|---|
::toc{maxLevel="3"} |
toc |
Table of contents |
::children{sort="title"} |
children |
Child pages |
::pagetree{root="Notes"} |
pagetree |
Page tree |
:::excerpt{name="…"} + body |
excerpt |
Excerpt definition (the bodied form) |
::excerptInclude[Page] |
excerpt-include |
Insert excerpt — the label is the target page |
::includePage[Page] |
include |
Include page — the label is the target page |
Macro parameters ride as directive attributes, and the unnamed parameter
of the macro is the [label]. Every name registers in all three
directive positions (::name, :name, :::name). The same macro key
genuinely appears as a block, an inline, and a bodied node in live pages,
and the ADF node type decides the form on the way back. A container that
holds nothing, such as :::toc with an empty body, encodes as the
bodiless macro: ADF gives bodiedExtension a required body, and
Confluence answers an empty one by dropping the macro.
Everything that Confluence derives is left out of the markdown. macroId
is server-generated, so a macro written without one comes back with one
filled in. schemaVersion and title are constant per macro key.
Therefore the encode synthesizes all three and the decode drops them, and
a plain table of contents is no more than ::toc. A value that
diverges from the per-key default survives as an explicit
schemaVersion= or title= attribute instead of a silent rewrite.
layout="default" is dropped, because it is the default.
The sugar claims only what it can carry exactly. Four cases decline and
degrade through the generic ::extension path with the parameters JSON
intact: an unsugared macro key, a non-string parameter, an unexpected
metadata field, and a parameter named like one of the reserved attributes
(layout, localId, schemaVersion, title). The sugar was measured
against 182 macro instances in 150 live pages. Every one round-tripped
through the sugar, and none degraded.
The skill/ submodule ships the markdown dialect as an
agent skill. It is an embedded bundle of SKILL.md plus
references/, and it holds the complete syntax, the ADF coverage, a
format-stable worked example, and the pitfalls. It teaches an AI coding
agent to read and write adfast-flavored markdown. A host serves it
through skill.Files(), an fs.FS, or materializes it with
skill.Install(dir) into its own agent-skills directory, such as
.claude/skills/.
Leading document metadata is pluggable through
WithFrontmatterProvider. The default handles YAML --- frontmatter.
Supply your own provider for another convention, for example the
<!-- Space: X --> HTML-comment headers of the Confluence sync tools.
The same provider drives BOTH directions, md → adf and the formatter, so
detection cannot diverge between them. A found block never reaches the
parser, and the style-preserving formatter re-emits it verbatim. A
provider can also report a block as malformed, which means that the
block opens the convention but does not close validly. The bytes are then
kept as body and a malformed-frontmatter diagnostic fires.
The core stays YAML-neutral. The front block is opaque bytes, kept
verbatim on ast.Frontmatter.Value, delimiters included. A consumer who
wants structured access to a YAML block opts into the
frontmatter/ submodule. It turns the raw block into a
map[string]any and back, and it does not couple the core to a YAML
implementation. It offers frontmatter.Parse and Render,
frontmatter.Patch (a merge under a caller-supplied top-level key order,
where a nil value erases the key), Replace, the nested dot-path helpers
Get, Set, and Remove, and KeyOrder, which reads the authored order
back out. It never re-implements boundary detection. That work stays with
the FrontmatterProvider, and frontmatter.ParseNode bridges straight
from an ast.Frontmatter node. For a hand-authored block where
formatting matters, frontmatter.PatchPreserving edits the changed keys
on the YAML CST only. It keeps the original key order, the comments, and
the scalar styles of everything that it does not touch.
The two halves of a composition drift apart easily. An extension that is
registered on the parse call only parses, but it never decodes back. A
Pipeline registers the cross-cutting options once for BOTH directions
and shows the composed one-shot conveniences, so every call reuses the
same options. A Pipeline is immutable and safe for concurrent use:
pipe := adfast.NewPipeline(adfast.WithPipelineOptions(
adfast.WithExtensions(youtubenode.Registration()), // parse AND decode
adfast.WithSmartLinks(jira.SmartLinks(baseURL)), // encode AND render
adfast.WithDiagnostics(sink), // every direction
adfast.WithCodeLanguages(jira.CodeLanguages), // encode-side check
))
doc := pipe.MarkdownToADF(md) // ToADF(FromMarkdown(md)) under the config
out := pipe.ADFToMarkdown(doc) // ToMarkdown(FromADF(doc)) under the config
pretty := pipe.Format(md) // the prettier md → md formatter, same configThere is one shared option type, so every cross-cutting option goes
through WithPipelineOptions. There are no direction-specific pipeline
constructors. pipe.MarkdownToADFAll(mds) is the batched variant. It
parses every document, runs the WithBeforeEncode(hooks…) hooks over the
whole set of parsed ASTs, and then encodes each one. Cross-document work,
such as a single batched asset upload, therefore happens before anything
encodes. pipe.ADFBytesToMarkdown(v) decodes raw ADF JSON, or any
decoded value, first. The free primitives stay as sugar for a one-off
call.
Atlassian's language picker accepts several spellings per language and
writes back exactly one of them: bash, sh, ksh and zsh are all
accepted, and the editor stores shell. So a fence written as
```bash pushes an alias, not the identifier the picker itself
would produce, and a document the editor has touched comes back spelled
differently from the one that was sent.
WithCanonicalCodeLanguages closes that. It takes an alias → canonical
map — adfast.AtlaskitCodeLanguageAliases, or its jira /
confluence clones — and applies it in the markdown → ADF direction
only:
opts := append(jira.MarkdownOptions(baseURL, jira.ExpandAuto),
adfast.WithCanonicalCodeLanguages(jira.CodeLanguageAliases))
doc := adfast.ToADF(adfast.FromMarkdown("```bash\necho hi\n```\n", opts...), opts...)
// codeBlock language: "shell"A tag with no entry in the map encodes verbatim, matching is
case-insensitive, and canonicalization runs before the
WithCodeLanguages check, so a normalized alias never reports
unsupported-code-language while an unknown tag still does. The map is
built from the same pinned upstream source as
adfast.AtlaskitCodeLanguages (see the doc comment in
codelanguages.go), so the two can never disagree about which spellings
exist.
The direction matters, and the trade is worth stating plainly. The
render side is untouched: ToMarkdown never rewrites a fence, so
formatting a working copy leaves ```bash exactly as the author
typed it. What is not recoverable is the alias once it has been
through ADF — the document holds shell and nothing else, so a raw pull
that overwrites the file writes ```shell. What the option buys
is that this stops being reported as a change: a diff that
canonicalizes its local side the same way (md → ADF → md) sees shell
on both sides. Do not pair it with a canonical → alias reverse map;
that would rewrite an author's genuine ```shell.
The option is opt-in and MarkdownOptions does not wire it, because
it changes the pushed payload.
- ADF → HTML rendering — adfast targets markdown, not display HTML. Use the frontend tooling of Atlassian to render ADF for viewing.
- Jira Data Center and Server — those APIs speak wiki markup, not ADF. adfast covers Cloud ADF only.
- Full API clients — adfast converts documents, and it does not talk to the Atlassian APIs. Pair it with a client library such as go-atlassian or go-jira for the transport.
The four primitives never return an error and never panic. FromMarkdown,
FromADF, ToADF, and ToMarkdown always produce a result. A lossy or
recovered situation flows through a diagnostics sink instead:
- an orphan
::colwidthsor::decisionsthat is dropped (colwidths-orphan,decisions-orphan), - a table span marker whose merge cannot apply (
span-marker-invalid), - a code-block language outside a configured
WithCodeLanguagesset (unsupported-code-language), - a node or a mark that the target product does not render
(
unsupported-in-product, described below), - a recovered parser panic (
parse-recovered), - an unknown ADF node that reaches the markdown projection (
raw-node), - a retired
:fontSizethat is dropped to plain text (fontsize-dropped), - a heading anchor dropped because the target product has no anchor
construct (
heading-anchor-dropped, fromWithoutHeadingAnchors), - an inline
rewritten as a link because ADF has no inline image for an external URL (inline-image-degraded), - a GFM footnote flattened to a superscript and a list at the end of the
document, because ADF has no footnote (
footnote-flattened), - a blockquote, a table or another block inside a list item, which ADF's
listItemcontent model cannot carry (list-item-content, described below).
One WithDiagnostics(func(convert.Diagnostic)) wires the sink into
whichever primitive emits: parse notices on FromMarkdown, encode
notices on ToADF, and decode notices on FromADF. Pass it to whichever
primitives a composition runs. Without a sink, every diagnostic is
silently dropped. Pipeline.MarkdownToADFAll is the errable batch
variant. A failure of a BeforeEncode hook, for example a batched asset
upload, aborts the call and returns the error.
The core conversion is universal, and it round-trips a Confluence
document faithfully. Product availability is therefore enforced as an
authoring-side diagnostic, not as a change to the conversion.
WithUnsupportedKinds(product, kinds) declares the ADF node kinds and
mark kinds that a target product does not render. After ToADF produces
the document, it walks both the nodes and the marks and emits one
unsupported-in-product diagnostic per distinct offending kind, for
example placeholder is not available in jira. No node is dropped and no
node is altered, and the output is byte-identical with and without the
option. The consumer decides the severity, and it can treat the
diagnostic as a blocking error before a Jira-targeted push.
The product sets are scoped to render-confirmed non-support. A full
live probe on 2026-07-22 showed each such kind dropped, shown as an
unsupported-content block, rejected by the ADF endpoint of the product,
or stripped or downgraded on save. The sets are not
documentation-by-omission, which proved unreliable. The Jira docs are
non-exhaustive, the Jira REST accepts most of the shared schema, and Jira
renders most omitted kinds first-class, layoutSection, cards, status, the
extension family, syncBlock, and the alignment, indentation, breakout,
annotation, fragment, and dataConsumer marks among them. Therefore
jira.UnsupportedKinds is placeholder, which the render drops, plus
multiBodiedExtension and extensionFrame, which the Jira REST endpoint
rejects with INVALID_INPUT. confluence.UnsupportedKinds is
blockTaskItem, which Confluence downgrades to a plain taskItem. Both
sets are wired through jira.MarkdownOptions and
confluence.MarkdownOptions. fontSize is in neither set, although both
products reject it. adfast retires the mark and never produces one,
described in the fontSize note below, so an unsupported-in-product
check for it would be moot. A new kind needs a live-probe confirmation,
not a missing docs page. The evidence and the full availability data live
in docs/adf-coverage.md and docs/adf-availability.json.
ADF gives listItem the content model (paragraph | bulletList | orderedList | taskList | mediaSingle | codeBlock | unsupportedBlock | extension)+ (the pinned schema oracle, docs/adf-coverage.md), a
single flat repeatable alternation, so a blockquote, a table, a heading,
a rule, a panel or a mediaGroup inside a list item is not representable,
however sensible the markdown that produced it. extension and
unsupportedBlock ARE in the alternation, so those two never raise this
diagnostic. ToADF does not restructure the author's document to fit
the model — lifting the block out of the item changes what the document
says, and re-nesting it changes the structure the author chose — so it
encodes exactly as written and emits one list-item-content diagnostic
per distinct offending kind instead. A live probe (2026-08-26) showed
Confluence accepting such a push, rendering the page correctly, and
rewriting the offending subtree on save into a bodiless
com.atlassian.confluence.migration / legacy-content extension;
confluence.ExpandLegacyContent reads that wrapper back. The pinned
model is one flat alternation with no first-position restriction to
enforce — older ADF schema revisions did restrict what could open a
listItem, but this repo's pin postdates that rule, so a list item whose
first block is a nested list (- - x) is not a violation at all, and
adfast does not report it.
The base dialect is CommonMark and GFM. It gives pipe tables, padded
to column width, with the cell merging of
remark-extended-table.
A cell that holds > only merges into the cell to its right, and a cell
that holds ^ only extends the cell above. Literal > and ^ cell
content is escaped. GFM also gives task lists (- [ ] and - [x]),
strikethrough, autolink literals, and footnotes ([^1] and
[^1]: note). On top of that come four things.
The first is decision lists, where a ::decisions leaf directive marks
the plain bullet list that follows it, exactly like ::colwidths marks
the table that follows. The second is YAML frontmatter, which is
pluggable through WithFrontmatterProvider. The third is heading anchors,
## Title {#my-anchor}, the pandoc spelling. The fourth is the directive
dialect below: :name[label]{attrs} inline, ::name[label]{attrs} as a
block leaf, and :::name … ::: as a container. Everything below
round-trips losslessly through ADF.
| Markdown | ADF | Notes |
|---|---|---|
:::info … ::: |
panel | Also note, warning, success, error |
:::expand[Title] … ::: |
expand | Title is optional; nests inside panels as nestedExpand |
:::media[alt]{…} … ::: |
mediaSingle + caption | The ::media attrs on the fence line, the caption as the body; a plain-text caption on image-expressible media uses the image title instead:  |
:::extension{…} … ::: |
bodiedExtension | Same attrs as ::extension; when every child is a :::frame container (extensionFrame) it encodes as multiBodiedExtension (a frameless one carries the bare multi); an empty body encodes as the bodiless extension |
:::syncBlock{resourceId localId} … |
bodiedSyncBlock | The source body of a synced block |
:::section + :::column{width="…"} |
layoutSection / layoutColumn | Page layouts; columnRuleStyle/localId on the section, width/valign/localId on each column |
:::center / :::end … ::: |
alignment mark | Block mark on each wrapped paragraph/heading |
:::indent{2} … ::: |
indentation mark | The bare value is the level (1–6) |
:::breakout{wide} … ::: |
breakout mark | Modes wide/full-width; optional width="1200" |
:::dataConsumer{sources="id1,id2"} … |
dataConsumer mark | sources is a comma-separated list of source ids (opaque strings; parsed by splitting on commas and trimming) |
:::fragment{localId="…" name?} … |
fragment mark | Stable references to tables/extensions |
A nested container grows the outer fence (::::), like remark. The
mark-wrapper containers (:::center and :::end, :::indent,
:::breakout, :::dataConsumer, :::fragment) put the ADF block
mark on every block that they wrap. The wrappers compose by nesting,
and the ADF mark array maps inside-out onto that nesting, with the first
mark innermost, so a round trip keeps the mark order. A single-valued
directive takes the bare-value attribute form ({2}, {wide},
{small}), where exactly one attribute with an empty value is the value
of the directive. When both forms are present, a named level=, mode=,
or size= attribute wins. An arbitrary-JSON payload, such as
parameters on an extension, uses a canonical JSON attribute
encoding: the output of json.Marshal, with sorted keys and no
insignificant whitespace. That JSON holds a ", so the attribute is
single-quoted and stays readable and lossless, as in
parameters='{"station":"rooftop"}'. When the JSON value itself holds a
', a single quote would not be lossless. The attribute then falls back
to double quotes and writes every " as ". This is
remark-compatible, because remark decodes a character reference in an
attribute value. adfast's own parse does not: it reads the literal
" back, and dialect.DecodeJSONAttr is what decodes it for the
JSON payload. So a value carrying both quote characters round-trips
only as a JSON attribute; there is no escape inside a quoted attribute
value, so any other attribute has to keep one of the two quotes out. The
sources attribute of dataConsumer is a plain comma-separated list of
source ids, and it is not JSON.
| Markdown | ADF | Notes |
|---|---|---|
::linkCard[ABC-123] |
blockCard | Bare keys expand via the configured SmartLinks resolver; full URLs also work: ::linkCard[https://…] |
::linkEmbed[https://…]{layout="center" width="80"} |
embedCard | layout/width mirror the embed attributes |
::media[shot.png]{#<media-uuid> collection height="551" layout="align-start" type="file" width="2308"} |
mediaSingle / media | Attachments; the label is the alt text (all attrs optional). type is file|external (default file); #<id> is the media id; url links type="external" media; collection/occurrenceKey are opaque strings kept when present; width/height are intrinsic pixel dimensions, layoutWidth/widthType carry display sizing; group="true" items reassemble a mediaGroup; path points at the downloaded local file |
::colwidths[79,320,200] |
table column widths | Placed directly before a table; widths re-apply to every row on encode. A ::colwidths with no following table is dropped with a colwidths-orphan diagnostic. Counts visual columns — a colspan cell carries one width per covered column |
::jql[project = X AND status = Open]{cloudId="…" datasource="…" columns="summary,status"} |
blockCard (JQL datasource) | Live JQL tables (Jira); columns lists the table-view keys, url is kept when present |
::extension{key="…" type="…" parameters='…' layout? localId? text?} |
extension | Bodiless macros; key/type are the ADF extensionKey/extensionType; parameters carries arbitrary JSON in the canonical attr encoding |
::syncBlock{localId="…" resourceId="…"} |
syncBlock | A reference to a synced block |
A media directive also carries the borderColor and borderSize
attributes, for the ADF border mark on the media node.
| Markdown | ADF | Notes |
|---|---|---|
:mention[Jane Doe]{#712020:aa…} |
mention | #… is the account id; accessLevel kept when present; a legacy leading @ in the label is accepted (stripped) |
:status[In Progress]{color="blue"} |
status | Lozenge; style kept when present |
:date[2026-07-15]{timestamp="1784073600000"} |
date | timestamp (ms since epoch) is authoritative; the label is the UTC day derived from it (and parses without one); localId kept when present |
:placeholder[Type something…] |
placeholder | Template placeholder text; localId kept when present |
:emoji{#custom-id shortName=":team_logo:"} |
emoji | Fallback for custom/site emojis only — see the emoji row in the coverage table. shortName required; #<id> and text (rendered fallback) optional |
:extension{key="…" type="…" …} |
inlineExtension | Inline macros; same attrs as ::extension minus layout |
:annotation[text]{#id annotationType="inlineComment"} |
annotation mark | Confluence inline-comment anchor — pushing a body without it orphans the comment thread, so the mark must survive |
:color[text]{color="#ff5630"} |
textColor mark | |
:bg[text]{color="#fffae6"} |
backgroundColor mark | |
:u[text] |
underline mark | |
:sub[text] / :sup[text] |
subsup mark | |
:fontSize[text]{small} |
retired | Parses (the bare value is the size; size="…" also parses) but is dropped to plain text — no product supports the mark. See the coverage table |
:media{#<media-uuid> collection} |
mediaInline | Inline attachment chip. type defaults to file and is left out when canonical; a bare collection is an empty collection, and its absence means none |
A mark directive nests with regular emphasis, as in
:color[**bold red**]{color="#ff5630"}. An inline mark directive wraps
per text run, in a fixed nesting order from outside to inside:
:annotation, :color, :bg, :u, and :sub or :sup. (:fontSize
is retired. It parses, but it drops to plain text.) A directive label
cannot nest brackets, so overlapping annotation marks on one text run
degrade to the outermost anchor.
Every known directive parses into a typed AST node in the package
dialect/, and that node implements the public extension
contract. An unknown directive name keeps the generic directive kinds. On
the way to ADF it degrades exactly like remark: a container dissolves into
its content, an unknown leaf drops, and an unknown text directive flattens
to text. The md → md formatter degrades none of them — it is total, and
an unknown directive comes back out as the author wrote it.
- Attachments as images — wire in a media-asset store, with
WithMediaAssetsorWithMediaAssetResolver, plusWithAssetIDResolverandWithImageDimsResolver. File media whose local copy carries every ADF property then renders as a plain, and it maps back to its media id on encode. Anything richer keeps the::mediadirective: a PDF, resized media, or a non-default layout. - Inline images — an image inside a paragraph, a table cell, or a
list item has three fates, because ADF's inline media covers only one
of them. A path the asset store maps to a media id becomes a
mediaInlinechip, the faithful form, and reads back as the samewhen the store is wired on the render side too. An absolutehttp(s)URL has no faithful form at all, becausemediaInlineaddresses an uploaded attachment by id and has no external variant, unlike block media. It therefore degrades to the link it can still be — the alt text is the label, the image URL the href — with aninline-image-degradeddiagnostic. Any other path is an asset not in the store yet, so it drops with anunresolved-assetdiagnostic that an upload flow can act on. - Footnotes — GFM footnotes,
a[^1]with[^1]: the note. The label rules are micromark's: no whitespace inside it, not even escaped ([^a b]:is a link reference definition), an escaped\[allowed where a raw one is not, and a reference with no definition in the same document stays literal text. The md → md route keeps both ends where the source put them, so the formatter never moves, sorts, or deletes a definition. ADF has no footnote of any kind, so the ADF route flattens: each reference becomes its number as superscript text, and every definition collects at the end of the document behind arule, as oneorderedListwhose item numbers are those numbers. The numbering is definition order, the order of that list. A reference carries no link to its definition, because ADF has no anchor to link to. This is the one construct that does not come back:adf → mdreturns the flattened form, and each flattened footnote reports afootnote-flatteneddiagnostic. - Heading anchors —
## Title {#my-anchor}gives the heading an explicit anchor id. This is the spelling of pandoc and of remark-heading-id. The id must match[0-9A-Za-z][0-9A-Za-z._-]*, and a space must separate it from the heading text. Any other form stays literal text, and an escaped brace (## Title \{#lit}) always stays literal. ADF has no platform-neutral anchor, so the id rides as a synthetic never-wire attribute (adf.Heading.Anchor) that the addon of the host product resolves.confluence.MarkdownOptionslowers it to the anchor macro of Confluence,confluence.RenderOptionslifts it back, andjira.MarkdownOptionsdrops it with aheading-anchor-droppeddiagnostic. - Issue links — a link whose text equals the resolver-derived key,
for example
[ABC-123](https://…/browse/ABC-123), becomes an inlineCard. - Image titles as captions —
maps the title to a mediaSingle caption child. A richer caption, with formatting or a hard break, uses the:::mediacontainer form. - Tables — GFM pipe tables. A header row is synthesized when the ADF
table has none. Column alignment (
|:--|--:|:-:|) survives the ADF route: ADF tables have no alignment attribute of any kind, so the per-column list rides as a synthetic never-wire attribute (adf.Table.Align), and the render places the colons and the cell padding exactly where remark-stringify does. Both product bundles lower the attribute onto the ADF alignment mark of each cell block (adf.LowerTableAlign), and read it back (adf.LiftTableAlign). The mark spells only center and end, so a left-aligned column comes back unaligned, which is what it renders as.
One document that exercises most of the dialect, usable as a template. It
round-trips through FromMarkdown → ToMarkdown unchanged, and a test
extracts this block and asserts that:
---
title: Rooftop apiary — season plan
labels: [bees, community-garden]
---
# Rooftop apiary — season plan
Inline directives annotate prose without leaving the line. This plan is kept by :mention[Maya Winters]{#712020:aa11}, a mention that links to a person; its :status[In Progress]{color="blue"} shows a colored status lozenge; and the first inspection :date[2026-04-12]{timestamp="1775952000000"} renders as a real date chip 🐝
:::info
An `info` panel frames helpful context in a colored callout. Everything inside is ordinary markdown that survives the round trip to ADF and back, including the :annotation[inline comments]{#c9e1 annotationType="inlineComment"} your co-keepers leave — an annotation anchors a Confluence comment thread to a span of text, so the thread stays attached across edits.
:::
## Season scope
Text marks add formatting inline: **bold** and _italic_ for emphasis, ~~three hives~~ struck through for a retraction, and `varroa` in code for a literal term. A ratio reads :sub[1] as subscript to :sup[1] as superscript; :color[red]{color="#ff5630"} sets the text color and :bg[highlight]{color="#fff0b3"} the background; and :u[underline] underlines a run.
A trailing backslash forces a hard line break:\
so this clause starts on its own line. New keepers sign the [rota](#rota) where a :placeholder[your name here…] marks an empty template field to fill in later — that link points at a heading's explicit anchor id, written as a `{#rota}` suffix on the heading itself.
A task list tracks work with checkboxes — `[ ]` is open and `[x]` is done:
- [ ] assemble the new brood boxes
- [x] order spring sugar syrup
- [ ] paint the new stands
A loose item keeps indented follow-up blocks: use the leftover green from the shed door.
The `::decisions` marker turns the bullet list that immediately follows it into a decision list, so each item reads as a recorded decision:
::decisions
- we requeen Hive B this season, Hive A next year
- no honey harvest before the summer solstice
An ordered list numbers its steps in sequence, and a thematic break closes the section:
1. Clean and scorch the empty boxes
2. Split the strongest colony
3. Merge the nucleus before winter
---
## Hive setup
:::expand[Why a vertical hive stand?]
An `:::expand` is a collapsible section — readers click the title to reveal the body, which keeps long asides out of the way. Links work inside it: see [BEE-42](https://hive.example.org/browse/BEE-42) and the club wiki at https://wiki.example.org/apiary.
:::
::::warning
A `warning` panel flags something to be careful about: mind the parapet ledge when hauling supers. Panels nest, so a collapsible section fits inside one:
:::expand[Storage map]
Frames live in the attic crates; smoker fuel stays in the metal locker. A :emoji{#1f9a9-custom shortName=":county_bee:"} falls back to a custom emoji, resolved by its short name when it is not a standard unicode glyph.
:::
::::
A fenced code block keeps source verbatim, tagged with its language for highlighting:
```python
if colony.strength() > SPLIT_THRESHOLD:
apiary.split(colony)
```
## Inspection rota {#rota}
`::colwidths` pins each column's pixel width for the table that follows; the table itself supports spans, where `>` merges a cell leftward (colspan) and `^` merges it upward (rowspan):
::colwidths[120,80,220]
| Keeper | Week | Notes |
| ------ | ---- | --------------------------- |
| Maya | 15 | queen spotting |
| > | Sam | mite count |
| Priya | ^ | shares the mite-count sheet |
## Task board
A `::jql` block embeds a live Jira query as a datasource table, naming the columns to display:
::jql[project = BEE AND fixVersion = season-2026 ORDER BY rank]{cloudId="abc-123" columns="summary,status,assignee" datasource="d8b52e33-6a5d-4c6e-8f6a-1b2c3d4e5f60"}
A `::linkCard` renders a URL as a rich preview card:
::linkCard[https://hive.example.org/browse/BEE-42]
A `::linkEmbed` embeds the target inline, sized by its layout and width attributes:
::linkEmbed[https://wiki.example.org/apiary/map]{layout="center" width="80"}
Extensions host third-party macros. An inline one drops a widget mid-sentence — hive scale: :extension{key="scale" type="com.example"} — where `key` and `type` name the macro and `parameters` carries its JSON config:
::extension{key="weather-widget" parameters='{"station":"rooftop"}' type="com.example.apiary"}
A bodied extension wraps block content that the macro renders:
:::extension{key="inspection-log" type="com.example.apiary"}
Entries in this body render inside the inspection-log macro.
:::
A multi-bodied extension gives the macro several `:::frame` bodies — here one tab per season:
::::extension{key="season-tabs" type="com.example.apiary"}
:::frame
Spring: feed, inspect, split.
:::
:::frame
Summer: supers on, harvest after solstice.
:::
::::
## Shared checklists
A `:::syncBlock` defines reusable block content that other pages embed by its `resourceId`:
:::syncBlock{localId="safety-1" resourceId="ari:cloud:example:page/123"}
Zip the suit before opening any hive.
:::
The leaf `::syncBlock` embeds that shared content here by reference:
::syncBlock{localId="safety-1" resourceId="ari:cloud:example:page/123"}
## Layout
A `:::section` lays `:::column` blocks out side by side, each sized by `width`, and `:::center` centers a column's content:
:::section
:::column{width="50"}
:::center
**Before**: two weathered hives
:::
:::
:::column{width="50"}
:::center
**After**: painted stands, three colonies
:::
:::
:::
A `:::indent` shifts a block to the right by a level:
:::indent{2}
The nucleus stands two paces in from the parapet edge.
:::
A `:::end` aligns its content to the end of the column:
:::end
Wind readings align to the end of the content column.
:::
A `:::dataConsumer` marks a block as reading from named sources — here the task-board datasource above, referenced by its id:
:::dataConsumer{sources="d8b52e33-6a5d-4c6e-8f6a-1b2c3d4e5f60"}
This summary re-reads the task-board datasource above.
:::
A `:::fragment` gives a block a stable id and name so other macros can reference it:
:::fragment{localId="rota-fragment" name="Inspection rota"}
Other macros reference this block by its fragment name.
:::
## Attachments
An image with a title becomes a captioned figure:

A leaf `::media` attaches a file by its media id:
::media[hive-inspection-sheet.pdf]{#b5773183-5f9a-481f-b1b8-8fe286bba8e9}
A bodied `:::media` adds a caption beneath the attachment:
:::media[hive stand sketch]{#0f4b9a2c-3d5e-4f60-8a71-92b3c4d5e6f7 height="480" layout="center" width="640"}
Sketch of the **vertical** stand — drawn by Sam.
:::
An inline :media{#7c1e0d2a-4b3f-45e8-9a2b-6c5d4e3f2a1b collection} drops an attachment mid-sentence — here the field kit.
Finally, a `:::breakout` lets a block escape the content column's width:
:::breakout{wide}
> This wide quote breaks out of the content column.
:::The tables below hold every node type and mark type in the ADF schema of
Atlassian
(@atlaskit/adf-schema,
full and stage-0, cross-checked against the
ADF reference).
Each row states whether the kind can occur in the documents of that
product, and how adfast treats it.
Fully cited matrix: every row below lives in docs/adf-coverage.md, with the upstream schema-definition link of each kind, pinned to a mirror commit SHA, and the exact Jira and Confluence evidence behind its marker. The machine-readable form is docs/adf-availability.json. Both columns were confirmed empirically on 2026-07-22 against a live Cloud site. Every node and mark was written to a Jira issue and to a Confluence page, and the product-rendered DOM was inspected. The Confluence page was also read back, to see what survives the save. Read the "Empirical validation" section of the cited matrix.
Per-product marker — whether the kind can occur in the documents of that product. As of 2026-07-22 the markers reflect live render and round-trip evidence, not documentation alone:
- ✓ — available. The product renders it first-class, or renders it degraded but present (Jira), or keeps it on save (Confluence).
- ∘ — present in the shared ADF schema, but genuinely untestable here, for example attachment-gated file media.
- — — not available. The render drops it, the ADF endpoint of the product rejects it, or the save strips or downgrades it.
adfast support — the handling of adfast itself, independent of product availability:
- converted — the kind has a markdown mapping and round-trips through it.
- preserved — the kind survives an ADF decode → encode losslessly,
typed or as a
RawNodeorRawMark, but the markdown projection drops or reduces it, with araw-nodediagnostic. - dropped — deliberately retired. adfast never produces the kind, and
a legacy instance decodes to plain text with a
fontsize-droppeddiagnostic. The text is kept and the styling is lost.fontSizeis the only such kind, because no Atlassian product supports the mark.
The product-availability diagnostic uses the render-confirmed
not-available set. Read
Product availability.
jira.UnsupportedKinds is placeholder, which the render drops, plus
multiBodiedExtension and extensionFrame, which the Jira REST endpoint
rejects with INVALID_INPUT. Jira renders every other kind that the probe
covered. confluence.UnsupportedKinds is blockTaskItem, which
Confluence downgrades to a plain taskItem. fontSize is in neither set.
Both products reject it, but adfast retires the mark and never produces
one, so the check would be moot.
| ADF node | Jira | Confluence | adfast support | Markdown mapping / notes |
|---|---|---|---|---|
| doc | ✓ | ✓ | converted | document root |
| paragraph | ✓ | ✓ | converted | paragraph |
| text | ✓ | ✓ | converted | plain text carrying the marks below |
| heading | ✓ | ✓ | converted | #–######, with a trailing {#id} as the explicit anchor id (see Related conventions) |
| blockquote | ✓ | ✓ | converted | > |
| rule | ✓ | ✓ | converted | --- |
| codeBlock | ✓ | ✓ | converted | fenced code block; fence grows past embedded backtick runs; language survives |
| bulletList / orderedList / listItem | ✓ | ✓ | converted | - / 1. lists; marker alternation between adjacent lists; order start preserved |
| taskList / taskItem | ✓ | ✓ | converted | - [ ] / - [x]; localId regenerates as empty on encode |
| blockTaskItem | ✓ | — | converted | - [ ] + indented blocks; a single-paragraph item re-encodes as the inline taskItem. Jira renders it first-class; Confluence downgrades it to a plain taskItem |
| decisionList / decisionItem | ✓ | ✓ | converted | ::decisions + following plain bullet list; encodes with state DECIDED; Jira renders decisions first-class (live 2026-07-22) |
| table / tableRow / tableHeader / tableCell | ✓ | ✓ | converted | GFM pipe table; colspan/rowspan via >/^ markers; colwidth attrs via ::colwidths; column alignment rides the synthetic never-wire align attribute (ADF has none), which the product bundles lower onto the alignment mark of each cell block |
| panel | ✓ | ✓ | converted | :::info …; unknown panelType degrades to info |
| expand / nestedExpand | ✓ | ✓ | converted | :::expand[Title] …; encode always emits expand (Jira nests it as nestedExpand itself) |
| mediaSingle / mediaGroup / media | ✓ | ✓ | converted |  or ::media; plain image only when fully expressible; groups fan out to group="true" items |
| mediaInline | ∘ | ✓ | converted | :media{…} inline attachment chip, or an inline  the asset store maps to a media id; an inline image with an external URL has no ADF form and degrades to a link. Jira is attachment-gated — not injection-testable with synthetic ids, so left inconclusive |
| caption | ✓ | ✓ | converted | image title () when plain text on image-expressible media, else the :::media body |
| inlineCard | ✓ | ✓ | converted | [KEY](url) link; encodes back to inlineCard when the label equals the resolver-derived key |
| blockCard | ✓ | ✓ | converted | ::linkCard[…]; URL-less cards are dropped |
| blockCard + datasource | ✓ | ∘ | converted | ::jql[…]{…} — only the documented jira/jql shape; richer shapes fall back to ::linkCard |
| embedCard | ✓ | ✓ | converted | ::linkEmbed[…]{…} |
| mention | ✓ | ✓ | converted | :mention[Name]{#id} |
| emoji | ✓ | ✓ | converted | with a text attr: that text (deliberately lossy — shortName/id degrade to plain text across markdown); without: unicode via the emoji-toolkit shortname table, else :emoji{shortName…} |
| status | ✓ | ✓ | converted | :status[Text]{color} |
| date | ✓ | ✓ | converted | :date[2026-07-15]{timestamp="…"}; the timestamp attribute is authoritative |
| hardBreak | ✓ | ✓ | converted | backslash / trailing-space break. A newline in the text node beside the break is the producer's wrap — Confluence writes one after every <br/> — and drops, rather than folding to a space markdown could only write as   |
| placeholder | — | ✓ | converted | :placeholder[Type something…] |
| layoutSection / layoutColumn | ✓ | ✓ | converted | :::section containing :::column{width="…"} containers. Jira renders a real multi-column layout (live 2026-07-22) |
| extension / bodiedExtension / inlineExtension | ✓ | ✓ | converted | ::extension{…} / :::extension{…} + body / :extension{…}. Jira renders them (ak-renderer-extension / inline fallback); Confluence resolves known macros, and confluence.Macros() sugars the common ones (::toc, :::excerpt, ::includePage[Page], …) |
| multiBodiedExtension / extensionFrame | — | ✓ | converted | :::extension{…} whose children are all :::frame containers; stage-0 schema. Jira REST rejects them (INVALID_INPUT); Confluence preserves them |
| syncBlock / bodiedSyncBlock | ✓ | ✓ | converted | ::syncBlock{…} (reference) / :::syncBlock{…} + body (source). Jira renders the sync-block widget (live 2026-07-22) |
| ADF mark | Jira | Confluence | adfast support | Markdown mapping / notes |
|---|---|---|---|---|
| strong | ✓ | ✓ | converted | **bold** |
| em | ✓ | ✓ | converted | _italic_ |
| strike | ✓ | ✓ | converted | ~~strike~~ |
| code | ✓ | ✓ | converted | `code`; exclusive like ADF (strong/em/strike stripped) |
| underline | ✓ | ✓ | converted | :u[text] |
| link | ✓ | ✓ | converted | [label](url) incl. titles |
| subsup | ✓ | ✓ | converted | :sub[text] / :sup[text] |
| textColor | ✓ | ✓ | converted | :color[text]{color="#ff5630"} |
| backgroundColor | ✓ | ✓ | converted | :bg[text]{color="#fffae6"} |
| border | ✓ | ✓ | converted | borderColor/borderSize attributes on the media directive forms |
| alignment | ✓ | ✓ | converted | :::center / :::end wrapper around the block. Jira renders it first-class (fabric-editor-alignment, live 2026-07-22) |
| indentation | ✓ | ✓ | converted | :::indent{level} wrapper around the block. Jira renders it first-class (fabric-editor-indentation, live 2026-07-22) |
| breakout | ✓ | ✓ | converted | :::breakout{mode} wrapper around the block. Jira renders it first-class (live 2026-07-22) |
| annotation | ✓ | ✓ | converted | :annotation[text]{#id annotationType} — keeps Confluence inline-comment threads anchored across markdown edits; overlapping anchors on one text run degrade to the outermost. Jira renders it (live 2026-07-22) |
| dataConsumer | ✓ | ✓ | converted | :::dataConsumer{sources="id1,id2"} wrapper around the block (sources is a comma-separated id list). Both products preserve/render the mark (live 2026-07-22) |
| fragment | ✓ | ✓ | converted | :::fragment{localId name?} wrapper around the block. Both products preserve/render the mark (live 2026-07-22) |
| fontSize | — | — | dropped | Retired — no product supports the mark (Jira REST rejects it with INVALID_INPUT; Confluence strips it on save). :fontSize[text]{size} still parses but unwraps to plain text on encode, and a legacy fontSize ADF mark decodes to bare text; both emit a fontsize-dropped diagnostic (text kept, size lost) |
In short: unknown or undocumented ADF content survives an ADF-level round trip losslessly, and diagnostics can report it. Only the markdown projection reduces it. Every kind in the table has a markdown mapping, so a document also survives markdown-only persistence: render, store the file, re-parse, and push. The mechanics are documented in docs/design.md: the RawNode and Extra preservation, the diagnostic codes, and the few deliberate edge-case losses.
The extension/ package defines the public contract for a
custom node kind. A kind must support all four pipeline paths. A
capability fragment, render-only or encode-only, is rejected at
registration:
- md → ast: a parse constructor promotes a generic directive node
(
Registration.Containers/Leaves/Texts, keyed by directive name). - ast → md: the node's
RenderMarkdownwrites its directive form throughextension.RenderContext. - ast → adf: the node's
EncodeADFreturns its ADF form throughextension.EncodeContext. - adf → ast: a decode hook recognizes the ADF shape the kind owns
(
Registration.DecodeBlock/DecodeBlockList/DecodeInline).
The known dialect, in the package dialect/, is implemented
on exactly this contract. It is therefore both the default registration
set and the reference implementation. Here is a complete custom kind: a
fictional :youtube[dQw4w9WgXcQ] inline directive for an invented
youtube ADF node.
package youtubenode
import (
"github.com/pmarschik/adfast/adf"
"github.com/pmarschik/adfast/ast"
"github.com/pmarschik/adfast/extension"
)
// YouTube is :youtube[videoId] ⇄ a fictional "youtube" ADF node.
type YouTube struct {
Children []ast.Node // the video-id label
}
func (*YouTube) Kind() string { return "youtube" }
func (n *YouTube) ChildNodes() []ast.Node { return n.Children } // ast.Parent
func (n *YouTube) SetChildNodes(kids []ast.Node) { n.Children = kids }
func (*YouTube) MarkdownLead() byte { return ':' } // extension.InlineLead
// ast → md
func (n *YouTube) RenderMarkdown(ctx extension.RenderContext) {
ctx.WriteTextDirective("youtube", nil, n.Children)
}
// ast → adf
func (n *YouTube) EncodeADF(_ extension.EncodeContext) []adf.Node {
id := ast.PlainText(n.Children)
if id == "" {
return nil // drop, like remark degradation
}
// Unknown-to-adfast kinds are built as RawNode (the decoder would
// produce the same shape for them).
return []adf.Node{&adf.RawNode{Type: "youtube", Attrs: map[string]any{"videoId": id}}}
}
// Registration bundles the remaining two paths: md → ast and adf → ast.
func Registration() extension.Registration {
return extension.Registration{
Kind: "youtube",
Texts: map[string]func(*ast.TextDirective) extension.Node{
"youtube": func(d *ast.TextDirective) extension.Node {
return &YouTube{Children: d.Children}
},
},
DecodeInline: func(n adf.Node, _ extension.DecodeContext) ([]ast.Node, bool) {
raw, ok := n.(*adf.RawNode)
if !ok || raw.Type != "youtube" {
return nil, false
}
id := adf.StrAttr(raw.Attrs, "videoId")
if id == "" {
return nil, true // owned, but not representable: drop
}
return []ast.Node{&YouTube{Children: []ast.Node{&ast.Text{Value: id}}}}, true
},
}
}Wire it in. One adfast.WithExtensions covers every direction, because
the facade forwards to markdown.WithExtensions on the parse and to
convert.WithExtensions on the encode and the decode. The default
dialect set stays active. Register the same bundle on both halves of a
composition, so that the parse leg and the decode leg never drift apart:
reg := adfast.WithExtensions(youtubenode.Registration())
doc := adfast.ToADF(adfast.FromMarkdown(md, reg), reg)
out := adfast.ToMarkdown(adfast.FromADF(doc, reg), reg)A block kind also embeds ast.BlockSpacing, the blank-line structure.
When it renders a ::: container, it implements
extension.ContainerForm, so that the enclosing fences grow around it.
Three dialect behaviors deliberately stay structural in convert,
because they cross node boundaries: the ::colwidths ↔ table attachment,
the ::decisions ↔ bullet-list marking, and the decode of the inline
mark directives, which ADF stores as text marks instead of nodes. Read
the documentation of the dialect package.
The module is layered into public subpackages along the pipeline stages, and the root package is a thin facade that composes them. The full package notes live in docs/design.md.
| Package | Purpose |
|---|---|
adfast (root) |
The facade: the four FromMarkdown/FromADF/ToADF/ToMarkdown primitives, Pipeline, the shared option set |
adf/ |
Typed ADF document model + JSON codec; lossless RawNode/RawMark/Extra preservation |
ast/ |
The pivot Markdown AST (remark-mdast-shaped) both directions share |
extension/ |
Public AST extension contract (Node, context interfaces, Registration) |
dialect/ |
The known directive dialect as typed AST nodes; wired as the default set |
markdown/ |
Text edge: goldmark parser assembly + remark-compatible renderer |
convert/ |
AST ⇄ ADF transforms (ToADF, FromADF) and their parameter types |
assets/ |
Pluggable attachment store behind the media resolvers — see Asset store |
debug/ |
Tree dumps of both ASTs; debugging aid only |
jira/ |
Separate module: Jira conventions (MarkdownOptions, RenderOptions, EncodeRichText, CodeLanguages) |
confluence/ |
Separate module: Confluence conventions (MarkdownOptions, RenderOptions, page SmartLinks, CodeLanguages, macro sugar via Macros) |
skill/ |
Separate module: the dialect as an embeddable agent skill (Files, Install) |
frontmatter/ |
Separate module: optional YAML frontmatter access (Parse, Render, Patch, PatchPreserving, path helpers) |
wasm/ |
Separate module: a js/wasm build exposing conversion and directive span scanning to JavaScript — a build artifact, not an importable API |
The root module is platform-neutral ADF ⇄ Markdown. A platform-specific
addon ships as a separate submodule, so a consumer pulls only what it
uses: jira/, confluence/, the skill/ agent-skill bundle, and the
optional frontmatter/ YAML helpers. Smart-link recognition stays in the
root module, for bare issue keys, /browse/ URLs, and inline cards.
Confluence content links to a Jira issue through the same ADF nodes.
wasm/ is a submodule for the same reason, but it is a different kind of
thing: no Go code imports it. It compiles to WebAssembly and it is
consumed from JavaScript. It is therefore versioned and tagged with the
rest, but delivered as a .wasm file instead of a go get.
The assets package is the media seam behind the resolvers. Store is a
storage-agnostic interface. Nothing in it assumes a filesystem, so an
in-memory backend or an object-storage backend, on S3 or elsewhere, is
equally implementable. Scope is the one cross-cutting concern that every
store honors, because a media id is valid inside a product container
only. FSStore is the shipped default: a free-form assets/ folder
next to your markdown files. It adds content-addressed deduplication as
an implementation detail, and the interface neither requires that nor
knows about it. NewFSStore keeps the assets next to the documents.
NewFSStoreAt and NewFSStoreSplit separate the physical location from
the documents, and assets.Layered stacks stores. One call wires a store
into each half of a composition:
store, _ := assets.NewFSStore(mdDir)
mdOpts := assets.MarkdownOptions(store)
doc := adfast.ToADF(adfast.FromMarkdown(md, mdOpts...), mdOpts...)
rOpts := assets.RenderOptions(store)
out := adfast.ToMarkdown(adfast.FromADF(doc, rOpts...), rOpts...)Markdown-first assets are supported. Reference a local file before any
upload, and the encode side reports an unresolved-asset diagnostic
instead of a silent drop. The upload seam is the pluggable Uploader
interface. assets.Sync(ctx, store, uploader) uploads the pending
worklist in one batch. assets.PushPipeline(ctx, store, uploader)
returns an adfast.Pipeline that uploads the referenced pending assets
automatically, immediately before the encode, through a
WithBeforeEncode hook on the pipeline. assets.EnsureUploaded syncs
first and returns the wired markdown options.
assets.RewriteReferences(old, new) re-paths the image references
through the formatter, as a WithASTTransforms transform, after a change
to the store layout.
An attachment has a product-side container boundary. A Jira media id is
bound to one issue, and a Confluence one to one page.
assets.ForScope(store, "KEY-123") binds a view to one container, so
every document encodes ids that are valid for its container, while
local storage stays deduplicated.
The store internals are documented in docs/design.md: the on-disk layout, the atomicity, the layered and split stores, and the scoping model.
Both trees ship exhaustive, type-safe visitors: ast.Visitor[T] with
ast.Visit, and adf.Visitor[T] with adf.Visit, plus
adf.MarkVisitor[T]. A visitor needs one method per kind, so a new
node kind breaks every visitor at compile time. That is the enforcement
a Go type switch cannot give. The open-set escapes are explicit. An
unknown markdown kind, which is an extension node or a dialect node,
dispatches to VisitExtension. Unknown ADF content dispatches to
VisitRaw or VisitRawMark.
type linkCollector struct{ urls []string }
// … one method per kind; the compiler tells you which are missing …
func (c *linkCollector) VisitLink(l *ast.Link) struct{} {
c.urls = append(c.urls, l.URL)
return struct{}{}
}For an ADF-side traversal that needs no per-kind dispatch, adf.Walk
iterates every node of a subtree as a Go iterator: for n := range adf.Walk(root). adf.Transform(doc, f) rewrites a document
copy-on-write. Per node, f returns a replacement and a handled flag,
which gives three actions: replace the node, erase it with an empty
slice, or prune a subtree from the rewrite by a return of the node
itself. The input document is never mutated. The jira transforms, the
issue-link → inlineCard rewrite and the bare-key expansion, are built on
adf.Transform.
An extension package chains the exhaustiveness. dialect.Visitor[T] with
dialect.Visit covers the typed dialect kinds. Call it from your
VisitExtension, and a kind that the dialect does not know continues to
its VisitOther, where a further extension visitor chains. An extension
author who ships custom kinds must follow the same escape-and-chain
shape.
Read CONTRIBUTING.md for the mise toolchain, the hooks, the test commands, and the workflow of the fixture corpus and the fuzz corpus. Releases are documented in docs/RELEASING.md.