# ADR-0020: JFM — A Markdown Dialect for Bidirectional ADF Interchange

## Status

✅ Accepted

## Context

omni-dev's Atlassian integration reads and writes JIRA issues and Confluence
pages. Both systems store rich-text content in Atlassian Document Format (ADF),
a JSON-based block/inline node tree. Users need to view and edit this content
locally, and pushing changes back must not silently alter the document —
round-trip fidelity is a hard requirement because ADF carries semantic metadata
(inline comment annotations, task item state, layout attributes, media
references) that would be lost if the intermediate format were lossy.

Four approaches were considered:

- **Raw ADF JSON editing.** Users edit the JSON tree directly. This preserves
  every attribute but is hostile to human editing: deeply nested objects,
  mandatory boilerplate fields, and no visual correspondence to the rendered
  page. Practical only for programmatic use.

- **Confluence wiki markup.** Atlassian's legacy markup language. Confluence
  Cloud no longer uses it internally — all storage is ADF. A wiki markup layer
  would require building a second bidirectional converter (wiki ↔ ADF) for a
  format that Atlassian is phasing out, with no guarantee of covering newer ADF
  node types.

- **Standard CommonMark with a parser library (e.g., pulldown-cmark).** Standard
  markdown parsers do not understand ADF-specific constructs (panels, expands,
  status badges, mentions, dates, inline comments, layout sections, media
  nodes). Extending a standard parser to emit and consume these constructs would
  require either post-processing the AST to inject custom nodes — fighting the
  parser's own model — or maintaining a fork. Neither approach composes well with
  the roughly 30 ADF node types that have no markdown equivalent.

- **A custom markdown dialect using Generic Directives.** Extend markdown with
  the CommonMark Generic Directives syntax proposal to represent ADF-specific
  nodes. Standard markdown constructs (headings, lists, bold, links) map
  directly to their ADF counterparts. Non-standard constructs use a three-tier
  colon-prefixed directive syntax that is human-readable and machine-parseable.
  A bespoke line-oriented parser handles both standard markdown and directives
  in a single pass.

The Generic Directives syntax (`:name[content]{attrs}` for inline,
`::name[content]{attrs}` for leaf blocks, `:::name{attrs}` ... `:::` for
containers) provides a uniform extension mechanism without inventing ad hoc
syntax for each ADF node type. It is an existing proposal with prior art in
Pandoc and markdown-it, so the syntax is familiar to users who have encountered
those tools.

## Decision

We will define JFM (JIRA-Flavored Markdown) as a markdown dialect that
provides lossless bidirectional conversion between markdown text and ADF v1.

### Format structure

A JFM document consists of YAML frontmatter (delimited by `---`) followed by a
markdown body. The frontmatter carries metadata (issue key, summary, status,
assignee for JIRA; page ID, title, space key for Confluence). The body carries
the rich-text content, rendered as markdown with directive extensions.

### Standard markdown mapping

ADF nodes that have direct markdown equivalents use standard syntax:

| ADF Node        | Markdown                       |
|-----------------|--------------------------------|
| `heading`       | `# ` through `###### `         |
| `paragraph`     | Plain text                     |
| `codeBlock`     | Fenced code blocks (`` ``` ``) |
| `bulletList`    | `- item`                       |
| `orderedList`   | `1. item`                      |
| `taskList`      | `- [ ] ` / `- [x] `            |
| `blockquote`    | `> text`                       |
| `rule`          | `---`                          |
| `table`         | Pipe-delimited tables          |
| `strong`        | `**bold**`                     |
| `em`            | `*italic*`                     |
| `code`          | `` `code` ``                   |
| `strike`        | `~~text~~`                     |
| `link`          | `[text](url)`                  |

### Generic Directives for ADF-specific nodes

Three directive tiers represent ADF constructs that have no native markdown
form:

- **Inline** (`:name[content]{attrs}`) — `status`, `date`, `mention`, `emoji`,
  `inlineCard`, `placeholder`, `mediaInline`, inline `extension`.
- **Leaf block** (`::name[content]{attrs}`) — `embedCard`, block-level
  `extension`, empty paragraphs.
- **Container** (`:::name{attrs}` ... `:::`) — `panel`, `expand`,
  `nestedExpand`, `layoutSection`, `layoutColumn`, `decisionList`,
  `bodiedExtension`, `table` (when directive form is needed for complex cell
  content).

Bracketed spans (`[text]{attrs}`) represent inline marks that have no markdown
syntax: `underline`, `textColor`, `subsup`, and `annotation` (Confluence
inline comments).

### Lossless round-trip guarantees

Two mechanisms ensure that converting ADF → markdown → ADF reproduces the
original document:

1. **Attribute preservation.** ADF attributes that affect semantics but have no
   visual markdown representation are carried as directive attributes or
   trailing `{key=value}` annotations. This includes `localId` (used by JIRA
   and Confluence for task items, tables, expands, layout columns, and other
   stateful nodes), `parameters` blocks on extensions, `border` marks on media
   and table cells, and `breakout` widths.

2. **Unsupported node escape hatch.** ADF node types that the converter does
   not recognise are serialised as fenced code blocks with the language tag
   `adf-unsupported`, containing the node's raw JSON. On the return trip, the
   parser detects this tag and deserialises the JSON back into the original
   `AdfNode`. This ensures that documents containing future or rare ADF nodes
   survive a round-trip without data loss, even though the converter cannot
   render them as readable markdown.

### Parser approach

The converter uses a bespoke line-oriented parser (`MarkdownParser`) rather
than an off-the-shelf CommonMark library. The parser processes the input as a
`Vec<&str>` of lines with a cursor, dispatching to block-type recognisers in
priority order. Inline content is parsed by a character-level scanner that
handles markdown formatting, directives, bracketed spans, and the escaping
rules described below.

This approach was chosen because standard CommonMark parsers do not support
Generic Directives, and the ADF node model requires context-sensitive handling
(e.g., `hardBreak` continuation lines within list items, `localId` trailing
attributes, NBSP-only paragraphs) that would be difficult to retrofit into an
external parser's AST.

### Text escaping for round-trip safety

Plain text content that would be misinterpreted by the markdown parser on the
return trip is escaped during ADF-to-markdown rendering:

- Emphasis markers (`*`) are backslash-escaped to prevent spurious bold/italic.
- Backticks are escaped to prevent spurious code spans.
- Square brackets in link-marked text are escaped to prevent link syntax
  ambiguity.
- Bare URLs in text (not link-marked) are escaped to prevent auto-link
  detection.
- Emoji-like `:shortcode:` patterns in plain text are escaped to prevent emoji
  node creation.
- Trailing double-spaces are escaped to prevent `hardBreak` misinterpretation.
- Literal backslashes are escaped to prevent silent consumption.

## Consequences

**Positive:**

- Users can read and edit JIRA issues and Confluence pages in any text editor
  using familiar markdown syntax, without needing to understand ADF's JSON
  structure.

- The `adf-unsupported` escape hatch means the converter does not need to
  support every ADF node type before being useful. New node types can be added
  incrementally; unrecognised nodes survive round-trips in the meantime.

- The Generic Directives syntax provides a single, learnable extension
  mechanism rather than ad hoc syntax for each ADF-specific construct. Users
  who learn the `:name[content]{attrs}` pattern can read any directive without
  per-node-type documentation.

- Attribute preservation via trailing `{key=value}` annotations keeps the
  markdown body self-contained — no sidecar file or external database is needed
  to track ADF metadata.

**Negative:**

- The bespoke parser is a significant maintenance surface. At approximately
  17,000 lines (including 660+ regression tests), `convert.rs` is the largest
  file in the project. Each new ADF node type or edge case in round-trip
  behaviour requires parser changes.

- The escaping rules add visual noise to the markdown output for documents
  that contain characters with special meaning (backticks, asterisks, square
  brackets, colons). The output is optimised for fidelity over readability in
  these cases.

- JFM is a project-specific format. Files are not directly renderable by
  standard markdown viewers — directive syntax appears as literal text, and
  `{key=value}` annotations are visible. This limits the usefulness of JFM
  files outside of omni-dev workflows.

**Neutral:**

- The `adf-unsupported` escape hatch means any ADF document can round-trip
  today, but the rendered markdown quality depends on how many node types the
  converter handles natively versus how many fall through to the JSON escape.
  As coverage grows, the proportion of readable markdown increases.

- The `RenderOptions.strip_local_ids` flag provides a clean-output mode that
  omits `localId` annotations for contexts where round-trip fidelity is not
  needed (e.g., displaying content to the user). This is a presentation
  concern, not a format concern — the canonical JFM format includes all
  attributes.
