confmark 0.2.0

Bidirectional CLI tool for converting Markdown to Confluence markup and Confluence markup back to Markdown.
Documentation

confmark

CI crates.io docs.rs License Rust

Bidirectional converter between Markdown (CommonMark + GFM) and Confluence Storage Format (XHTML), as a Rust library and a CLI.

Both directions parse into a single shared document AST that renders out to either format, so md → cf → md and cf → md → cf preserve every supported construct — and anything unmappable is preserved verbatim rather than dropped.

Install

Prebuilt binary (Linux / macOS, x86_64 + arm64):

curl -fsSL https://raw.githubusercontent.com/MrEhbr/confmark/main/install.sh | sh

Installs to /usr/local/bin if writable, otherwise ~/.local/bin. Override with --bin-dir <dir> or pin a release with --version <tag>:

curl -fsSL https://raw.githubusercontent.com/MrEhbr/confmark/main/install.sh | sh -s -- --version v0.1.0 --bin-dir ~/.local/bin

With cargo-binstall (fetches the same prebuilt binary):

cargo binstall confmark

From source:

cargo install --path . # from a checkout
# or
just install

CLI

confmark is a Unix filter: it reads stdin and writes stdout by default, and also accepts file arguments.

# Markdown -> Confluence (stdin -> stdout)
echo '# Hello' | confmark --from md --to cf
# <h1>Hello</h1>

# Confluence -> Markdown, short flags
confmark -f cf -t md page.xml
# reads page.xml, writes Markdown to stdout

# File in, file out
confmark -f md -t cf notes.md -o notes.xml

# `-` is an explicit stdin/stdout sentinel
cat notes.md | confmark -f md -t cf -

Library

use confmark::Document;

let xml = Document::from_markdown("# Title").to_confluence();
assert_eq!(xml, "<h1>Title</h1>");

let md = Document::from_confluence("<h1>Title</h1>").to_markdown();
assert_eq!(md, "# Title");

Document is the entry point: from_markdown / from_confluence parse, to_markdown / to_confluence render.

The shared AST is also traversable for extracting data — blocks() and inlines() yield every node in depth-first document order:

use confmark::{Document, ast::{Inline, LinkTarget}};

let doc = Document::from_markdown("see [a](https://a.test) and [b](https://b.test)");
let urls: Vec<&str> = doc
    .inlines()
    .filter_map(|inline| match inline {
        Inline::Link { target: LinkTarget::External(url), .. } => Some(url.as_str()),
        _ => None,
    })
    .collect();
assert_eq!(urls, ["https://a.test", "https://b.test"]);

Supported constructs

Group Coverage
Blocks headings, paragraphs, code blocks, lists, blockquotes, thematic breaks
Inlines strong, emphasis, strikethrough, inline code, links, images, line breaks
GFM tables (alignment md→cf only), strikethrough, task lists, autolinks
Links external + Confluence resource links (page / attachment / anchor) via a reversible confluence:// URI
Macros code (↔ fenced block), admonitions info/note/tip/warning (↔ GFM alerts), expand (↔ <details>), status/toc/panel and any unknown macro (↔ <!--cf:…--> markers)
Preservation unrecognized storage elements survive as RawConfluence (<!--cf-raw:…--> in Markdown)

The full Markdown ↔ AST ↔ Confluence contract is in docs/MAPPING.md, backed by the round-trip fixtures in tests/fixtures/.

Known lossy points: per-column table alignment is dropped on md→cf (storage format has no standard for it); admonition styling beyond type is not expressible as a GFM alert.

Development

just build # build
just test  # cargo nextest
just lint  # clippy + rustfmt --check
just fmt   # format

The toolchain is split: rust-toolchain.toml pins stable for build/test/clippy; rustfmt runs on nightly (the config uses nightly-only options).

Scope

Confluence target is Storage Format (the REST interchange XHTML) only — not Wiki Markup or ADF, and no live REST API integration. Markdown is CommonMark + GFM.

License

Licensed under MIT. See LICENSE for details.