# @larvit/adf-codec — ADF to markdown and HTML, markdown to ADF [![npm](https://img.shields.io/npm/v/@larvit/adf-codec)](https://www.npmjs.com/package/@larvit/adf-codec) [![CI](https://github.com/larvit/adf-codec/actions/workflows/ci.yml/badge.svg)](https://github.com/larvit/adf-codec/actions/workflows/ci.yml) Convert between ADF (Atlassian Document Format, the rich text of Jira and Confluence) and markdown or HTML. For application developers whose app shows, edits, exports or generates the rich text Atlassian products store as ADF. ## Main functionality * `portableMarkdownToAdf` — use this if you "just have markdown". It reads CommonMark plus GFM's tables, task lists and alerts, `~~`, and Obsidian's callouts and `==`. * `commonMarkToAdf` — reads strict CommonMark only. Extensions like tables or `~~` come out as plain text. * `adfToLosslessMarkdown` — ADF to adf-codec's own markdown flavour, which spells ADF's special nodes (panels, mentions, statuses and so on) and carries node types this version doesn't know, so nothing is lost. * `losslessMarkdownToAdf` — the way back: gives you the ADF you started from. * `adfToPortableMarkdown` — ADF to markdown that GitHub, GitLab and Obsidian render. Keeps the content and drops colours, layout and other attributes. * `adfToHtml` — ADF to HTML. * `htmlToAdf` — HTML to ADF. ## Features and perks * No runtime dependencies. * No React, no Atlaskit. * Runs on Node, Forge, Deno, Bun and browsers. * Nothing to configure or connect. * Ships TypeScript types. * Errors are returned, never thrown. * Every error has a code from a closed list. Atlaskit's markdown transformer converts markdown to ADF, and its ADF to markdown throws. [Other ADF converters](#other-adf-converters) compares this package with it and the rest. ## Install ```sh npm install @larvit/adf-codec ``` ESM only, Node 18 or later ([The package](#the-package)). Upgrading: [MIGRATION.md](https://github.com/larvit/adf-codec/blob/main/MIGRATION.md). ## Quick start ### ADF to markdown Full reference: [The calls](#the-calls), [The lossless flavour](#the-lossless-flavour) and [Portable markdown](#portable-markdown). ```ts import { adfToLosslessMarkdown, adfToPortableMarkdown, type AdfDocument } from '@larvit/adf-codec' const doc: AdfDocument = { content: [ { attrs: { panelType: 'warning' }, content: [{ content: [{ text: 'Check the collation before importing.', type: 'text' }], type: 'paragraph' }], type: 'panel', }, ], type: 'doc', version: 1, } const lossless = adfToLosslessMarkdown(doc) if (lossless.ok) console.log(lossless.value) // !adf:panel warning // Check the collation before importing. // !adf:/panel const portable = adfToPortableMarkdown(doc) if (portable.ok) console.log(portable.value) // > [!WARNING] // > // > Check the collation before importing. ``` ### Markdown to ADF Full reference: [The calls](#the-calls) and [Portable markdown](#portable-markdown). ```ts import { portableMarkdownToAdf } from '@larvit/adf-codec' const result = portableMarkdownToAdf('# Release notes\n\nRead the _manual_ before **wiring**.\n') if (result.ok) { // result.value deep-equals: // { // content: [ // { attrs: { level: 1 }, content: [{ text: 'Release notes', type: 'text' }], type: 'heading' }, // { // content: [ // { text: 'Read the ', type: 'text' }, // { marks: [{ type: 'em' }], text: 'manual', type: 'text' }, // { text: ' before ', type: 'text' }, // { marks: [{ type: 'strong' }], text: 'wiring', type: 'text' }, // { text: '.', type: 'text' }, // ], // type: 'paragraph', // }, // ], // type: 'doc', // version: 1, // } } ``` `portableMarkdownToAdf` also reads `> [!NOTE]` alerts, `- [x]` tasks, `==highlights==` and pipe tables as nodes. For markdown that `adfToLosslessMarkdown` wrote, use `losslessMarkdownToAdf`. For markdown known to be strict CommonMark, use `commonMarkToAdf`. ### The lossless round-trip Full reference: [The guarantees](#the-guarantees). ```ts import { adfToLosslessMarkdown, losslessMarkdownToAdf } from '@larvit/adf-codec' const markdown = adfToLosslessMarkdown(doc) if (!markdown.ok) { showReadOnly(doc, markdown.error) // saving markdown you could not produce would lose content } else { const edited = await editInYourApp(markdown.value) // a human, a model, a template const saved = losslessMarkdownToAdf(edited) if (saved.ok) post(saved.value) else showError(saved.error) // the edit broke the markdown: the error names line and node } ``` Unedited, the markdown reads back to a document that deep-equals `doc`, node types this version does not know included. ### ADF to HTML Full reference: [HTML](#html). ```ts import { adfToHtml, htmlToAdf, type AdfDocument } from '@larvit/adf-codec' const page: AdfDocument = { content: [ { attrs: { level: 2, localId: 'a1f3' }, content: [{ text: 'Release notes', type: 'text' }], type: 'heading' }, { content: [ { text: 'Read the ', type: 'text' }, { marks: [{ attrs: { href: 'https://spec.commonmark.org/' }, type: 'link' }], text: 'spec', type: 'text' }, { text: ', then ask ', type: 'text' }, { attrs: { id: '5b10a2', text: '@Mikael' }, type: 'mention' }, ], type: 'paragraph', }, ], type: 'doc', version: 1, } // Sanitize HTML from an author you do not trust before showing it: URLs are written as the ADF holds them, javascript: included. const html = adfToHtml(page) if (html.ok) console.log(html.value) //

Release notes

//

Read the spec, then ask @Mikael

const back = html.ok ? htmlToAdf(html.value) : undefined if (back?.ok) console.log(back.value) // deep-equals page ``` ### Handle a result's error Full reference: [The errors](#the-errors). ```ts import { losslessMarkdownToAdf } from '@larvit/adf-codec' const result = losslessMarkdownToAdf('Part | Qty\n--- | ---\nBolt M8 | 40\n') if (!result.ok) { const { code, message, path, position } = result.error // code: 'malformed-pipe-table' — the rows open with no pipe // position: { line: 1, offset: 0 } // path: ['content', 0] console.error(`${code} at line ${position.line}, ${path.map((step) => '/' + step).join('')}: ${message}`) } ``` Nothing throws. `code` is from a closed list, so a `switch` with no `default` covers every case; `message` is free text for a person fixing the input. ## Contents - [Goals](#goals) - [Audience](#audience) - [Other ADF converters](#other-adf-converters) - [The calls](#the-calls) - [The lossless flavour](#the-lossless-flavour) - [Portable markdown](#portable-markdown) - [HTML](#html) - [The errors](#the-errors) - [The guarantees](#the-guarantees) - [The package](#the-package) - [Everything exported](#everything-exported) - [Changing the source](#changing-the-source) - [Links](#links) ## Goals The most useful ADF conversion library available, judged by these goals. A Maintain goal holds whatever it costs; the Achieve goals are weighed against each other in the order listed. 1. **Maintain:** Lossless, and every call returns a result, never a throw. 2. **Maintain:** ADF is the hub. 3. **Achieve:** Easy to find, and clear at a glance what it does. - Signal: weekly npm downloads rise. - Counter-signal: issues asking what a call does, or saying it did something else, rise. - Why: users won by a vague pitch come back as confusion. 4. **Achieve:** Each format reads and writes as its standard says. - Signal: fewer exceptions are listed under [The guarantees](#the-guarantees). - Counter-signal: breaking releases rise. - Why: conformance bought by changing stored formats costs every consumer a migration. 5. **Maintain:** Our markdown is CommonMark, extended only where CommonMark has no spelling. 6. **Achieve:** No surprises: output reads and edits the way its audience expects. - Signal: readers shown the candidate outputs pick the one the library already writes. - Counter-signal: `spec/flavour.md` gains a special case for each such pick. - Why: output fitted case by case is output no reader can predict. 1. **Maintain:** A function's name says which markdown it reads or writes. 7. **Maintain:** Lossy conversion drops form, never content. A link's or an image's title counts as content. 8. **Maintain:** Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect. 9. **Maintain:** Fast, and linear in the document's size as JSON. ## Audience Application developers embedding the library, in four personas. All four rely on the guarantees below and on an error's `code` being a closed list; none may rely on an error message's wording, which is free text. - **Viewer/editor app** — shows a document, lets a human edit, posts it back. Relies on the round-trip holding for whatever the site's editor wrote, unknown node types included, and on a refusal arriving before the save rather than after. - **Bot posting content** — turns generated markdown into ADF. Relies on CommonMark being valid input, so nothing upstream has to learn a flavour. - **Export/indexing tool** — converts ADF to markdown or HTML in bulk. Relies on readable output and on every refusal but `engine-limit` being deterministic, so a document that fails fails the same way next run. - **LLM/agent pipeline** — hands documents to a model as markdown and writes the edits back. Relies on the round-trip and on markdown a reader half-knowing the lossless flavour can still edit. Behind those apps, the people who read and write the markdown and the HTML: product managers, engineers and support agents working in Atlassian products through a plugin or another UI. They know markdown and not ADF, and rely on every spelling saying what it means to them. Contributors, human or agent, who change the source. They rely on each concern having one obvious place, and on CI failing a change that breaks a guarantee. ## Other ADF converters Serves [Goals](#goals) 3. The first table lists every npm package that runs in Node, converts ADF to or from markdown, whatever its downloads: one that starts and converts anything is listed, and its row says where it fails ordinary input. A wrapper or fork of a listed package is left out. The list comes from npm searches on 2026-11-05 for "adf markdown", "markdown to adf", "adf to markdown", "atlassian document format" and "adf converter". On 2026-11-05, [`converter-comparison/`](https://github.com/larvit/adf-codec/tree/main/converter-comparison) ran each other package's published code at the version the table names, and this package's latest release, with default options. A package writing markdown got a document holding marks, a link, a mention, a status, a panel and a list, and a document holding a node type it cannot know. A package reading markdown got markdown holding the same marks and link. | Package | Converts | ADF to markdown to ADF | A node type it does not know | Formats | Direct runtime dependencies | | --- | --- | --- | --- | --- | --- | | `@larvit/adf-codec` | both ways | the same document | kept | markdown: lossless, portable; CommonMark in; HTML out; its own HTML in | 0 | | `@atlaskit/editor-markdown-transformer` 6.3.1 | markdown to ADF, through `@atlaskit/editor-json-transformer` 10.0.0; ADF to markdown throws "This is not implemented yet" | – | – | markdown | 5 | | `@faulander/md2adf` 1.0.0 | both ways | a different document: the status is gone, the mention's `id` becomes its text and the panel a quote | dropped with its text | markdown | 2 | | `@nthings.dev/adf4j-wasm` 1.0.2 | ADF to markdown | – | replaced by the text `[Unsupported: futureThing]` | GitHub Flavored Markdown, from WebAssembly | 0 | | `@wrtnlabs/markdown-to-adf` 1.0.2 | markdown to a list of blocks, not a document; fails ordinary input: `***bold italic***` comes out as italic text holding asterisks, and a paragraph holding a hard break stands between blocks | – | – | markdown | 2 | | `adf-converter` 0.1.0 | ADF to markdown; writes a mention as `@@Ann` | – | its text kept, with a console message | markdown | 0 | | `adf-md` 0.2.0 | both ways | a different document: the status without its `localId`, the marks in the other order | dropped with its text | markdown with MDX tags for panels, mentions and statuses | 6 | | `adf-to-markdown` 1.0.1 | ADF to markdown | – | dropped with its text | markdown | 0 | | `adf-to-md` 1.2.1 | ADF to markdown | – | dropped with its text, as are mentions, statuses and panels | markdown | 1 | | `adf2markdown` 1.2.0 | ADF to markdown | – | its text kept | markdown | 0 | | `extended-markdown-adf-parser` 2.4.0 | both ways | a different document: the mention comes back as linked text, the status without its `localId` | written as JSON, read back as a paragraph of that JSON's text | markdown | 9 | | `github-markdown-adf` 1.3.0 | both ways | a different document: the mention comes back as text, the status as inline code, the marks in the other order | dropped with its text | markdown | 3 | | `markdown-to-adf` 0.2.20 | markdown to ADF; fails ordinary input: a quote is written as the node type `blockQuote`, which ADF spells `blockquote` | – | – | markdown | 3 | | `marklassian` 1.2.1 | markdown to ADF | – | – | markdown | 1 | | `md-adf` 0.2.2 | both ways, with a warning for each fallback | a different document: the mention and the status come back as text, the marks in the other order, the panel as a quote | dropped with its text, with a warning | markdown | 5 | | `md-to-adf` 0.6.4 | markdown to ADF; fails ordinary input: each list item is written as a list of its own | – | – | markdown | 2 | | `mdast-util-from-adf` 2.3.0 | ADF to an mdast tree, which `mdast-util-to-markdown` 2.2.2 writes as markdown; throws on a status | – | throws | markdown | 3 | The next table lists converters this comparison does not run: other ecosystems' libraries, Atlassian's own paths, and one npm package that needs a browser. Each row is what the project's documentation says, on 2026-10-28. | Converter | Converts | Source | | --- | --- | --- | | `@atlaskit/editor-wikimarkup-transformer` 12.2.3 | ADF to and from Jira wiki markup, not markdown | [npm](https://www.npmjs.com/package/@atlaskit/editor-wikimarkup-transformer) | | `adf2md` 2.0.0, JavaScript for browsers | ADF to and from GitHub Flavored Markdown with MDX-style tags for Atlassian nodes; ships a browser bundle that needs a page, not Node | [npm](https://www.npmjs.com/package/adf2md) | | Atlassian Rovo MCP server | its sample skills read and write Confluence pages as markdown | [README](https://github.com/atlassian/atlassian-mcp-server) | | `atlassian-markdown-converter` 0.2.1, Rust | between markdown, GFM, Jira wiki markup, ADF and Confluence markup | [crates.io](https://crates.io/crates/atlassian-markdown-converter) | | Confluence Cloud REST API | converts ADF to and from storage format, and ADF to rendered HTML views; no markdown | [OpenAPI](https://developer.atlassian.com/cloud/confluence/swagger.v3.json) | | `github.com/ajbeck/adf-to-markdown` 1.1.2 and `github.com/ajbeck/goldmark-adf` 2.0.2, Go | ADF to markdown; markdown to ADF through goldmark | [README](https://github.com/ajbeck/adf-to-markdown), [README](https://github.com/ajbeck/goldmark-adf) | | `github.com/rgonek/jira-adf-converter` 1.0.0, Go | GFM to and from ADF | [README](https://github.com/rgonek/jira-adf-converter) | | `jc-adf` 0.2.0, Rust | markdown to and from ADF; a node type it does not know goes through an `adf:` fenced block | [crates.io](https://crates.io/crates/jc-adf) | | Jira Cloud REST API v3 | reads and writes ADF in comments and fields such as the description; `expand=renderedFields` returns the fields as HTML; no markdown | [OpenAPI](https://dac-static.atlassian.com/cloud/jira/platform/swagger-v3.v3.json) | | `marklas` 0.8.6, Python | GitHub Flavored Markdown to and from ADF | [PyPI](https://pypi.org/project/marklas/) | | `Meziantou.Framework.AtlassianDataFormat` 1.0.2, .NET | ADF to markdown | [NuGet](https://www.nuget.org/packages/Meziantou.Framework.AtlassianDataFormat) | | `pyadf` 0.5.2, Python | ADF to markdown; a node it cannot render becomes an HTML element holding its attributes | [README](https://github.com/YoungseokCh/pyadf) | ## The calls Serves [Goals](#goals) 1, 2 and 8. Pure functions, each taking a whole document and returning a whole result; no I/O, no configuration. [What each call writes](https://github.com/larvit/adf-codec/blob/main/docs/feature-table.md) sets example documents beside each writer's output and says whether that output reads back the same. ```ts adfToLosslessMarkdown(doc: AdfDocument): Result losslessMarkdownToAdf(markdown: string): Result readAdfDocument(v: unknown): Result commonMarkToAdf(markdown: string): Result adfToPortableMarkdown(doc: AdfDocument): Result portableMarkdownToAdf(markdown: string): Result adfToHtml(doc: AdfDocument): Result htmlToAdf(html: string): Result ``` Each call's name says which format it reads or writes: | Call | Reads or writes | Refuses with | | --- | --- | --- | | `adfToHtml` | writes HTML: the elements [HTML](#html) lists, and an `adf-carry` element holding everything else | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` | | `adfToLosslessMarkdown` | writes the lossless flavour: CommonMark plus GFM's pipe tables and `~~`, with `!adf:` directives and `adf:` fences for everything else | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` | | `adfToPortableMarkdown` | writes markdown GitHub, GitLab and Obsidian render; drops what they cannot show, and writes no directive | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` | | `commonMarkToAdf` | reads strict CommonMark as its spec says: a directive, a pipe table, `~~`, `==` and an `adf:` fence read as text and code | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` | | `htmlToAdf` | reads the HTML `adfToHtml` writes, also as a browser's `innerHTML` or a form post spells it, a carry element from its `data-adf`; refuses any other HTML, an edit to what a carry element shows included | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` | | `losslessMarkdownToAdf` | reads the lossless flavour, markdown `adfToLosslessMarkdown` wrote, edited or not | every code in [The errors](#the-errors) except `not-an-adf-document` and `unsupported-document-version` | | `portableMarkdownToAdf` | reads CommonMark, GFM's pipe tables and `~~`, alerts and task markers, Obsidian's callouts and `==`; a directive and an `adf:` fence read as text and code | `engine-limit`, `malformed-pipe-table`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` | | `readAdfDocument` | checks a parsed value you hold from a parser or a store, never JSON text, and returns a copy of it as an `AdfDocument`; writes nothing | `engine-limit`, `not-an-adf-document`, `unsupported-node-shape` | `readAdfDocument` reads the JSON you receive into an `AdfDocument`, copying it once. It copies own enumerable keys and data properties alone, so an accessor property or a Proxy that throws is `not-an-adf-document` and never a throw. The three writers read their document the same way. It checks the shape the converters require: `type: 'doc'`, a finite `version` (the converters take only 1), nodes holding only `attrs`, `content`, `marks`, `text` and `type`, attribute values that are JSON values, and no object holding itself. It reads a document nesting past 500 levels; the converters refuse that one with `unsupported-nesting-depth`. The types: ```ts type AdfDocument = { content?: AdfNode[]; type: 'doc'; version: number } type AdfNode = { attrs?: AdfAttributes; content?: AdfNode[]; marks?: AdfMark[]; text?: string; type: string } type AdfMark = { attrs?: AdfAttributes; type: string } type AdfAttributes = { [key: string]: JsonValue } type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue } type Result = { error: E; ok: false } | { ok: true; value: T } type ConvertError = { code: ConvertErrorCode; message: string; path: ConvertErrorPath; position?: SourcePosition } type ParseError = ConvertError & { position: SourcePosition } type ConvertErrorPath = readonly (number | string)[] type SourcePosition = { line: number; offset: number } ``` ## The lossless flavour Serves [Goals](#goals) 1, 5 and 6. The lossless flavour is CommonMark plus GFM's pipe tables and `~~`, with directives under one prefix, `!adf:`, and `adf:` code fences for everything else. The complete grammar, with every node's attributes: [`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md). ### What CommonMark and GFM spell Where CommonMark or GFM holds what a node carries, the node keeps that spelling. `adfToLosslessMarkdown` writes exactly one spelling of each; every CommonMark variant of the same document normalizes to it through the round-trip. | ADF | Markdown | | --- | --- | | `blockquote` | `> ` | | `bulletList`, `listItem` | `- ` items; adjacent lists of a kind alternate `- ` and `* ` | | `codeBlock` | a fenced code block, the language as info string, the fence longer than any backtick run inside; several text nodes are `!adf:codeBlock` holding one fence per node | | `hardBreak` | a backslash at the end of the line; `!adf:hardBreak{}` where no backslash can stand: a block's end, inside a heading, a pipe table cell or a directive's content, or a break carrying an attribute | | `heading` | `#` to `######` | | `link` | `[text](url "title")`; `` where the text is the destination and the destination is an absolute URI | | `mediaSingle` holding an external `media`, centered, uncaptioned | `![alt](url)`; `[![alt](url)](href)` where a link marks the image | | `orderedList` | `1.` `2.` `3.`, the first number the list's `order`; adjacent lists of a kind alternate `1.` and `1)` | | `paragraph` | one line, no soft wraps | | `rule` | `---` | | `table` of one header row and plain inline cells | a pipe table, every row opening and closing with a pipe | | `text` marked `code`, `em`, `strike`, `strong` | `` `code` ``, `_em_` (`*em*` inside a word), `~~strike~~`, `**strong**` | | `text` holding a carriage return | ` ` | | `text` holding a newline, a null character, or a space or tab CommonMark strips (a block's inline edges, either side of a hard break, a pipe cell's edges, an em, strong or strike spelling's inner edges) | that run as `!adf:text{text="…"}`, the rest plain: `!adf:text{text=" "}Two leading spaces` | Input normalizes: setext headings become ATX, indented code becomes a fence, `*em*` becomes `_em_` outside a word, entity references decode, a soft line break becomes a space. ### What directives spell Three forms, one grammar. A directive's name is the ADF node or mark type: ``` !adf:name[content]{attrs} inline, on one line !adf:name arg {attrs} a block leaf !adf:name arg {attrs} a block container, closed by !adf:/name ``` A node's content model picks the block form: a node taking content is a container. ``` !adf:panel warning Check the collation before importing. !adf:/panel !adf:expand {title="Full build log"} … !adf:/expand Ping !adf:mention[@Mikael]{id=5b10a2} — !adf:status[In review]{color=yellow} — !adf:date{timestamp=1756080000000} ``` | ADF | Directive | | --- | --- | | `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`, `orderedList`, `paragraph`, `rule` carrying `localId`, marks or a value CommonMark cannot spell | the same node in directive form, `!adf:paragraph {localId=…}` | | `border` | an inline directive mark around a `mediaInline`, the one inline node that takes it | | `caption`, `media`, `mediaGroup`, `mediaSingle` | the media family; an image CommonMark cannot spell, a stored file, a caption | | `date`, `emoji`, `inlineCard`, `mediaInline`, `mention`, `status` | inline leaves; `emoji`, `mention` and `status` spell their `text` in the `[content]` slot | | `decisionList`, `decisionItem` | containers | | `expand`, `nestedExpand` | containers, `title` an attribute | | `extension`, `bodiedExtension`, `multiBodiedExtension`, `extensionFrame` | a leaf, containers, `parameters` as JSON in an attribute | | `layoutSection`, `layoutColumn` | containers | | `link` with an attribute besides `href` and `title`, or a target CommonMark cannot spell | `!adf:link[text]{attrs}` | | `panel` | a container, the type its argument: `!adf:panel info` | | `subsup`, `textColor`, `underline` | inline directive marks around their text, `!adf:textColor[**Overdue**]{color="#ae2e24"}` | | `syncBlock`, `bodiedSyncBlock` | a leaf and a container | | `table`, `tableRow`, `tableCell`, `tableHeader` richer than a pipe table | containers nesting block content | | `taskList`, `taskItem`, `blockTaskItem` | containers, the state an argument: `!adf:taskItem DONE` | | any other node type, or a node where its spelling cannot stand | the opaque carry: a code fence ```` ```adf: ```` holding the node's JSON, or inline `!adf:carry{json="…"}` | - Attributes are `{key=value key2="two words"}`: keys alphabetical, a value bare where it is `[A-Za-z0-9_-]+`, else double-quoted with JSON string escaping. A number or JSON value is spelled as canonical JSON, with two exceptions. `colwidth` joins its widths with commas: `colwidth="340,420"`. A block's `marks` names each mark followed by its attributes, `marks="breakout{mode=wide}"`, and stays a JSON array where the flavour does not list a mark's type or one of its attributes, or a value holds another type than the flavour gives it. An empty `attrs`, `content` or `marks` key is spelled `{attrs=empty}`, `{content=empty}` or `{marks=empty}`. - Three reserved names build no node: `!adf:carry` is the inline opaque carry, `!adf:doc {content=none}` spells a document holding no `content` key, and `!adf:textBreak{}` parts two adjacent text nodes CommonMark would read back as one. - Input reads the canonical spelling alone: keys out of order, a value quoted where bare carries it, or a longer escape than needed is an error naming the spelling to write. - To keep a literal `!adf:` as text, escape it: `\!adf:`, block and inline alike. A code span and a code block hold `!adf:` as content. ## Portable markdown Serves [Goals](#goals) 7. Portable markdown is a second flavour of the same grammar. `adfToPortableMarkdown` writes markdown other tools render — GitHub, GitLab, Obsidian and the like — keeping the content and dropping the rest: colours, layout, identity and every other attribute. Content is what a reader of the rendered document sees or follows: its text, images, link targets and titles. It writes no directive. `portableMarkdownToAdf` reads CommonMark and `~~` as `losslessMarkdownToAdf` reads them, and the conventions below as nodes, taking other tools' spellings too. It reads GFM's pipe tables with or without leading pipes and drops their column alignment. A directive and an `adf:` fence read as CommonMark reads them, as text and code. A backslash keeps a marker as text: `\==x==`, `> \[!NOTE]`, `- \[x]`. Markdown `adfToPortableMarkdown` wrote reads back and writes again byte for byte; the document it came from does not come back. To edit a document and save it back, use `adfToLosslessMarkdown` and `losslessMarkdownToAdf`: saving what this pair read replaces mentions, attachments and macros with text. | ADF | Written | Read back | | --- | --- | --- | | `backgroundColor` | `==text==` | `==text==` on one line, the text touching both delimiters, bounded outside by whitespace, punctuation or a line edge, or touching a Han, Hangul, Hiragana, Katakana, Thai, Lao, Khmer or Myanmar character on either side, in the editor's default highlight `#f8e6a0` | | `decisionList` | a bullet list | — | | `expand`, `nestedExpand` | Obsidian's folded callout, `> [!NOTE]- Title` | `-` or `+` after any word, the rest of the marker's line the title; a link reads `text (target)`, or its text alone where the text is the target with or without `mailto:`; an expand inside an expand is a `nestedExpand` | | external `media` | in a block `![alt](url)`, or `[![alt](url)](href)` where a link marks it; inline `[alt](url)`, or `[alt](href) ([image](url))` where a link to another target marks it; an empty `alt` gives way to `href`, then `url` | — | | `inlineCard`, `blockCard`, `embedCard` | a link to the card's URL, else its name | — | | `layoutSection`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`, `extensionFrame`, `caption`, a node this version does not know | its blocks or its text | — | | `mention`, `status`, `emoji`, `date` | their text: `@` kept, a mention with none `@` and its id, an emoji its `shortName` without, a date `2026-09-13` in UTC | — | | `panel` | a GitHub alert, `> [!WARNING]`: info `NOTE`, note `IMPORTANT`, tip and success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE` | `NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error, and Obsidian's: hint tip; success, check, done success; attention warning; danger, failure, fail, missing, bug, error error; any other word info — in any case; the rest of the marker's line is the first paragraph | | `placeholder` | nothing | — | | stored `media`, `mediaInline`, `extension`, `inlineExtension` | their `alt` or `text` | — | | `table` | a pipe table: the first row its header, a cell's blocks on one line, a span kept under its header by empty cells | — | | `taskList` | `- [x] Done`, `- [ ] Todo`. An item keeps its paragraphs, then its nested task lists; the rest of the item stands after the list, which resumes at the next task | a bullet list whose every item is so marked, `[X]` too. An item keeps its paragraphs, then its nested task lists; the rest of the item stands after the list, which resumes at the next task | - Content the document only references, with no text of its own to keep, leaves an italic note naming it where it stood: `_(image not included)_` for stored media with no `alt`, `_(link card not included)_` for a card with neither URL nor name, an extension with no `text` its key, `_(jira-issues-table not included)_`, or `_(extension not included)_` without one, and `_(synced block not included)_` for a `syncBlock`. A link marking such a node stays around its note; its other marks drop. - `backgroundColor`, `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops, keeping its text, and so does a mark the flavour cannot spell where it stands. A line still read back wrong after 32 tries drops every `em`, `strike` and `strong` at once. Code text keeps no mark but `code` and `link`. - A newline in text is a hard break and in an expand's title a space, edge whitespace outside a link or code span, or beside a hard break a link crosses, is trimmed, carriage returns and null characters are removed, and an empty paragraph drops. - A text node's `content` drops: Atlassian's schema forbids it, so no rendered document shows it. - Adjacent lists of a kind alternate markers, `- ` and `* `, `1.` and `1)`, since CommonMark starts a new list where the marker changes; a task list counts as a bullet list, `* [x] Done`. - An ordered list numbered past `999999999` is a bullet list keeping its numbers as text. - A task list holding a block other than a task is a bullet list keeping its states as text: `- \[x] Done`. - Text that would read as a marker takes a backslash: `==` wherever it could open or close a highlight, `[!…]` opening a quote, asterisks that would read as a rule after a `* ` marker (`* \**`), and `[x]` or `[ ]` opening any list item, since GitHub reads that marker per item. - A link title takes a backslash before a quote, before a backslash that ends the title or that CommonMark would read as an escape, before an `&` opening an entity reference such as `&`, and in a table cell before a `|`: `[docs](https://example.com "AT\&T")`. - A node read back carries no `localId` except a `taskList`, `taskItem` or `blockTaskItem`, which Atlassian's schema requires one on: each gets a UUID v4 hashed from the whole markdown and its position, the same on every read. Join markdown bound for one document and read it once: the same markdown read twice into one document repeats its ids. ## HTML Serves [Goals](#goals) 1, 6 and 7. `adfToHtml` writes the nodes and marks the table lists as their HTML elements, and keeps every other node whole: an element of class `adf-carry` holds the node's JSON in `data-adf` and shows the node's content, its links included, so a reader of the page loses no content. No stylesheet ships. The complete dialect: [`spec/html.md`](https://github.com/larvit/adf-codec/blob/main/spec/html.md). | ADF | HTML | | --- | --- | | `blockquote` | `
` | | `bulletList`, `orderedList`, `listItem` | `