Help us shape the future of Go! Take the 2026 Go Developer Survey

adf

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 13, 2026 License: MIT Imports: 19 Imported by: 0

README

go-adf

Go Reference CI

Convert between Markdown and the Atlassian Document Format (ADF), the JSON format Jira Cloud and Confluence Cloud use for descriptions, comments and pages, in both directions, in pure Go.

doc, err := adf.FromMarkdown("Fixed in **v2.1**, see [notes](https://example.com/notes).")
md, err := adf.ToMarkdown(issue.Fields.Description)

Guarantees

These hold for every input, and are checked by tests and continuous fuzzing:

  • Valid ADF. Everything FromMarkdown produces validates against Atlassian's published ADF JSON schema. Markdown that ADF cannot hold in a given place is rewritten into the closest valid form (a heading inside a list item becomes a bold paragraph, nested quotes are flattened), instead of producing a document Jira rejects.
  • Stable round trips. Markdown written by ToMarkdown reads back as the same document. Text is escaped exactly as much as CommonMark needs: a #, *, 1. or [x] in a ticket stays literal text and does not turn into a heading, emphasis, a list or a link. Where Markdown cannot express a piece of formatting, it is left out rather than written in a way that reads back differently.
  • Safe for untrusted input. Neither direction panics on any input, nesting is limited (WithMaxDepth), and inputs known to slow down Markdown processing (thousands of brackets, delimiters or nested lists) are tested to convert quickly.

COVERAGE.md lists how every node and mark type of the ADF schema is converted.

Install

go get github.com/nyactl/go-adf

Requires Go 1.23 or newer. The only dependency is goldmark, a CommonMark parser.

Use

import adf "github.com/nyactl/go-adf"

// Markdown to ADF, e.g. for the body of a Jira comment.
doc, err := adf.FromMarkdown("## Steps\n\n1. Run `make test`\n2. Ship it")

// ADF to Markdown, e.g. a Jira description.
md, err := adf.ToMarkdown(rawDescription)

FromMarkdown reads CommonMark with the GitHub extensions for tables, strikethrough and task lists. ToMarkdown accepts any ADF document, including node types this version does not know, whose text content is kept. A JSON string instead of a document, which some Jira fields still hold, is returned as is.

Mentions

Mentions are written as @[label]. Two hooks decide how people are named and found, so an application can, for example, keep real names out of the Markdown it shows:

md, err := adf.ToMarkdown(raw, adf.WithMentionText(func(accountID, text string) string {
	return labels.For(accountID) // "Person A"
}))

doc, err := adf.FromMarkdown("Thanks @[Person A]!", adf.WithMentionResolver(func(label string) (accountID, text string, ok bool) {
	return labels.Lookup(label)
}))

Without a resolver, @[label] stays plain text. When the resolver does not know a label, FromMarkdown returns a *MentionError.

Expands, layouts, cards, colors and images

WithExtendedSyntax adds Markdown forms for what the Confluence and Jira editors make without any app. Both directions use them, and text that only looks like them is escaped:

<details data-breakout="wide:760">
<summary>Rollback steps</summary>

1. Stop the service

</details>

````columns
```column 50
Left
```

```column 50
Right
```
````

See <card:https://example.atlassian.net/browse/PROJ-1>, now {color:#006644}green{/color}.

<card-block:https://example.atlassian.net/browse/PROJ-2>

![Architecture](attachment:architecture.png "center 600px")

Images stored as attachments need WithMediaResolver, which maps a file name to its media node; external images (![alt](https://…)) work without it. The columns block needs a longer fence than its column blocks; malformed ones fail with ErrSyntax. Together with WithPassthrough, a page from the Confluence editor reads as Markdown with only app macros and the like as JSON.

Keeping what Markdown cannot express

Confluence pages hold app macros, panels, layouts, status lozenges and colored text, none of which Markdown has. By default ToMarkdown writes their text content, so a page read, edited and written back loses them. With WithPassthrough, both directions keep them exactly:

Release state: `adf:{"type":"status","attrs":{"color":"green","text":"DONE"}}`

```adf
{"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc"}}
```
  • A block without a Markdown form becomes a fenced code block with the info string adf holding the node's JSON; inline nodes and text with formatting Markdown lacks become inline code starting with adf:, several side by side in one JSON array.
  • FromMarkdown turns them back into the same nodes, numbers included, and fails with ErrRawNode when one is not a single valid node for its place. Without the option they stay code.
  • Attributes of nodes that do have a Markdown form, such as table widths or the local IDs Confluence gives paragraphs, are not kept.

The option lets the author of the Markdown write any node. Applications that take Markdown from others, such as an assistant, can pass WithRawNodes instead: its Write decides what is written in place of each such node, for example a placeholder that keeps the node's content out of the Markdown, and its Read turns what comes back into the node to use, or refuses it.

How it is tested

  • Every document produced in the tests is validated against Atlassian's ADF JSON schema (testdata/schema), and a test makes sure that check can fail.
  • Eight fuzz targets run in CI: Markdown always converts to valid ADF; ADF never makes ToMarkdown panic; Markdown is stable after one round trip; randomly generated ADF, with the formatting combinations Jira writes, is stable as well; and with WithPassthrough, generated ADF keeps every node without a Markdown form exactly, while any Markdown converts, fails only with ErrRawNode or ErrTooDeep, and is stable; and the same two with WithExtendedSyntax, where generated ADF with expands, layouts, cards, colors and images is stable and any Markdown gives valid ADF. Every input fuzzing has ever found is kept in testdata/fuzz and runs with the normal tests.
  • A coverage test fails when Atlassian's schema gains a type that COVERAGE.md does not describe.

Versioning

go-adf follows Semantic Versioning. Until v1.0.0, the API may still change between minor versions. From v1.0.0 on:

  • The Go API does not break within a major version; new node types and options are additions.
  • The exact Markdown text may change in a minor release when that makes the conversion more faithful. The guarantees above always hold.

Changes are listed in CHANGELOG.md.

Contributing

Bug reports with an input that converts wrongly are especially welcome. See CONTRIBUTING.md; security problems go through SECURITY.md.

License

MIT, see LICENSE. The ADF JSON schema used by the tests is Atlassian's, under the Apache License 2.0, see testdata/schema.

Not affiliated with or endorsed by Atlassian. Jira, Confluence and Atlassian are trademarks of Atlassian.

Documentation

Overview

Package adf converts between Markdown and the Atlassian Document Format (ADF), the JSON format Jira Cloud and Confluence Cloud use for rich text.

FromMarkdown always produces documents that validate against Atlassian's published ADF JSON schema: Markdown constructs that ADF cannot express in a given place are rewritten into the closest valid form (for example a heading inside a list item becomes a bold paragraph). ToMarkdown accepts any ADF document and falls back to the text content of node types it does not know.

Both directions are safe for untrusted input: they never panic, and nesting deeper than the configured limit is rejected with ErrTooDeep.

Example (ExtendedSyntax)
package main

import (
	"fmt"

	adf "github.com/nyactl/go-adf"
)

func main() {
	md := "<details>\n<summary>Details</summary>\n\nSee <card:https://example.atlassian.net/browse/PROJ-1>, {color:#006644}done{/color}.\n\n</details>"
	doc, _ := adf.FromMarkdown(md, adf.WithExtendedSyntax())
	fmt.Println(string(doc))
	back, _ := adf.ToMarkdown(doc, adf.WithExtendedSyntax())
	fmt.Println(back == md)
}
Output:
{"type":"doc","version":1,"content":[{"type":"expand","attrs":{"title":"Details"},"content":[{"type":"paragraph","content":[{"type":"text","text":"See "},{"type":"inlineCard","attrs":{"url":"https://example.atlassian.net/browse/PROJ-1"}},{"type":"text","text":", "},{"type":"text","text":"done","marks":[{"type":"textColor","attrs":{"color":"#006644"}}]},{"type":"text","text":"."}]}]}]}
true
Example (Mentions)

Mentions are written as @[label]. The hooks decide how people are named and resolved, for example to keep real names out of the Markdown.

package main

import (
	"fmt"
	"strings"

	adf "github.com/nyactl/go-adf"
)

func main() {
	people := map[string]string{"Person A": "5b10ac8d82e05b22cc7d4ef5"}
	resolve := adf.WithMentionResolver(func(label string) (string, string, bool) {
		id, ok := people[label]
		return id, label, ok
	})
	doc, err := adf.FromMarkdown("Thanks @[Person A]!", resolve)
	if err != nil {
		panic(err)
	}
	md, err := adf.ToMarkdown(doc, adf.WithMentionText(func(accountID, text string) string {
		return strings.TrimPrefix(text, "@")
	}))
	if err != nil {
		panic(err)
	}
	fmt.Println(md)
}
Output:
Thanks @[Person A]!
Example (Passthrough)
package main

import (
	"encoding/json"
	"fmt"
	"strings"

	adf "github.com/nyactl/go-adf"
)

func main() {
	page := `{"type":"doc","version":1,"content":[
		{"type":"paragraph","content":[{"type":"text","text":"Contents:"}]},
		{"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc"}}]}`
	md, _ := adf.ToMarkdown(json.RawMessage(page), adf.WithPassthrough())
	fmt.Println(md)

	edited := strings.Replace(md, "Contents:", "On this page:", 1)
	doc, _ := adf.FromMarkdown(edited, adf.WithPassthrough())
	fmt.Println(string(doc))
}
Output:
Contents:

```adf
{"type":"extension","attrs":{"extensionKey":"toc","extensionType":"com.atlassian.confluence.macro.core"}}
```
{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"text","text":"On this page:"}]},{"type":"extension","attrs":{"extensionKey":"toc","extensionType":"com.atlassian.confluence.macro.core"}}]}

Index

Examples

Constants

View Source
const DefaultMaxDepth = 100

DefaultMaxDepth is the nesting limit used unless WithMaxDepth sets one.

Variables

View Source
var ErrRawNode = errors.New("adf: invalid adf block")

ErrRawNode is returned by FromMarkdown, with WithPassthrough, for an adf block or span that does not hold one valid node for its place.

View Source
var ErrSyntax = errors.New("adf: invalid extended syntax")

ErrSyntax is returned by FromMarkdown, with WithExtendedSyntax, for a columns block whose column blocks are not as described there.

View Source
var ErrTooDeep = errors.New("adf: document is nested too deeply")

ErrTooDeep is returned for documents nested deeper than the limit.

Functions

func FromMarkdown

func FromMarkdown(markdown string, opts ...Option) (json.RawMessage, error)

FromMarkdown converts CommonMark with the GitHub extensions for tables, strikethrough and task list checkboxes to an ADF document.

Supported: paragraphs, headings, bullet and ordered lists, fenced and indented code, block quotes, thematic breaks, tables, strong, emphasis, strikethrough, inline code, links, autolinks and, with WithMentionResolver, @[label] mentions. Images become links. Raw HTML is kept as text.

Example
package main

import (
	"fmt"

	adf "github.com/nyactl/go-adf"
)

func main() {
	doc, err := adf.FromMarkdown("Fixed in **v2.1**, see [notes](https://example.com/notes).")
	if err != nil {
		panic(err)
	}
	fmt.Println(string(doc))
}
Output:
{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"text","text":"Fixed in "},{"type":"text","text":"v2.1","marks":[{"type":"strong"}]},{"type":"text","text":", see "},{"type":"text","text":"notes","marks":[{"type":"link","attrs":{"href":"https://example.com/notes"}}]},{"type":"text","text":"."}]}]}

func ToMarkdown

func ToMarkdown(raw json.RawMessage, opts ...Option) (string, error)

ToMarkdown renders an ADF document as Markdown. Empty or null input yields "", and a JSON string (which some Jira fields still hold instead of a document) is returned as is. Unknown node types fall back to their text content.

Example
package main

import (
	"encoding/json"
	"fmt"

	adf "github.com/nyactl/go-adf"
)

func main() {
	doc := json.RawMessage(`{"type":"doc","version":1,"content":[
		{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Steps"}]},
		{"type":"orderedList","content":[
			{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Run "},{"type":"text","text":"make test","marks":[{"type":"code"}]}]}]},
			{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Ship it"}]}]}]}]}`)
	md, err := adf.ToMarkdown(doc)
	if err != nil {
		panic(err)
	}
	fmt.Println(md)
}
Output:
## Steps

1. Run `make test`
2. Ship it

Types

type Mark

type Mark struct {
	Type  string         `json:"type"`
	Attrs map[string]any `json:"attrs,omitempty"`
}

Mark is formatting applied to a text node, such as strong or link.

type MediaError added in v0.2.0

type MediaError struct {
	Name string
}

MediaError is returned by FromMarkdown when an image refers to an attachment that the media resolver does not know, or when there is no resolver.

func (*MediaError) Error added in v0.2.0

func (e *MediaError) Error() string

type MentionError

type MentionError struct {
	Label string
}

MentionError is returned by FromMarkdown when the mention resolver does not know a @[label].

func (*MentionError) Error

func (e *MentionError) Error() string

type Node

type Node struct {
	Type    string         `json:"type"`
	Version int            `json:"version,omitempty"`
	Attrs   map[string]any `json:"attrs,omitempty"`
	Content []Node         `json:"content,omitempty"`
	Text    string         `json:"text,omitempty"`
	Marks   []Mark         `json:"marks,omitempty"`
}

Node is an ADF node.

type Option

type Option func(*config)

Option configures a conversion. Options that do not apply to a direction are ignored.

func WithExtendedSyntax added in v0.2.0

func WithExtendedSyntax() Option

WithExtendedSyntax adds Markdown forms for elements of the Confluence and Jira editors that CommonMark lacks. Both directions use them:

  • expand: <details>, an optional <summary>Title</summary> line and the body, closed by </details>; inside an expand it is a nestedExpand. The editor's width setting is kept as data-breakout="wide:760".
  • column layout: a fenced block with the info string columns holding one fenced block per column with the info string column and its width in percent, e.g. "column 50"; the width setting as in breakout=wide:760.
  • smart links: <card:URL> inline, <card-block:URL> and <card-embed:URL> on a line of their own.
  • text color and highlight: {color:#RRGGBB}text{/color} and {highlight:#RRGGBB}text{/highlight}.
  • images on a line of their own: ![alt](https://...) for external images, and ![name](attachment:name) for attachments, with WithMediaResolver. The title can hold the layout and the width: "center 600px" or "wide 50%".

Text that only looks like one of these forms is escaped, and code in the language columns loses that language, which would make it a layout (with WithPassthrough it is kept). A columns block with malformed column blocks makes FromMarkdown fail with ErrSyntax; one without any column block is code, and an image with a title other than these options stays a link.

func WithMaxDepth

func WithMaxDepth(n int) Option

WithMaxDepth sets the nesting limit for both directions.

func WithMediaResolver added in v0.2.0

func WithMediaResolver(f func(name string) (media Node, ok bool)) Option

WithMediaResolver lets WithExtendedSyntax write images stored as attachments. ToMarkdown names such an image by its alt text, usually the file name, and FromMarkdown asks the resolver for the media node of that name; when it reports false, FromMarkdown fails with a *MediaError. Without a resolver, ToMarkdown keeps writing attachments as before.

func WithMentionResolver

func WithMentionResolver(f func(label string) (accountID, text string, ok bool)) Option

WithMentionResolver makes FromMarkdown turn @[label] into mention nodes. The resolver returns the account ID and the display name to store; when it reports false, FromMarkdown fails with a *MentionError. Without a resolver, @[label] stays plain text.

func WithMentionText

func WithMentionText(f func(accountID, text string) string) Option

WithMentionText sets how ToMarkdown names a mentioned person. It receives the account ID and the text stored with the mention (usually "@Name") and returns the label written as @[label]. By default the stored text is used.

func WithPassthrough added in v0.2.0

func WithPassthrough() Option

WithPassthrough keeps what Markdown cannot express, so that FromMarkdown(ToMarkdown(doc)) keeps it unchanged. ToMarkdown writes such a block node, for example an app macro (extension, bodiedExtension), a panel or a table with merged cells, as a fenced code block with the info string adf holding the node's JSON. Such an inline node, for example a status or an inlineExtension, and text with formatting other than links, emphasis, strikethrough and code, become inline code holding adf: and the JSON. FromMarkdown turns both forms back into the node, after checking that it is one JSON object with a type that fits its place, and fails with ErrRawNode otherwise. Attributes of nodes that do have a Markdown form, such as table widths, are still not kept.

Callers that take Markdown from others decide which of these nodes they accept: the option lets the author write any node.

func WithRawNodes added in v0.2.0

func WithRawNodes(h RawNodes) Option

WithRawNodes turns on WithPassthrough and passes every node it writes or reads as JSON through h. Inline code that a span of nodes takes along is not passed.

type RawNodes added in v0.2.0

type RawNodes interface {
	// Write returns the node to write as JSON in place of n.
	Write(n Node) Node
	// Read returns the node to use for n, read from an adf block or, when
	// inline is set, from adf inline code. An error makes FromMarkdown fail
	// with it.
	Read(n Node, inline bool) (Node, error)
}

RawNodes decides, with WithRawNodes, what stands in the Markdown for the nodes WithPassthrough writes as JSON. An application can write a placeholder instead of a node whose content it does not want to show, and put the node back when reading, or refuse nodes it did not write.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL