Confluence as Code

Two-way documentation pipeline over Confluence, built from two tools with different markdown dialects:

  • Upstream (git → Confluence): mark (OSS) (kovetskiy, 1.6k★) — publishes markdown pages via REST API; <!-- Space: --> / <!-- Title: --> / <!-- Parent: --> header comments, macro definitions, --title-from-h1 --drop-h1, --changes-only, --track-pages.
  • Backup (Confluence → git): confluence-markdown-exporter (OSS) (cme, Spenhouet, 574★) — exports spaces/pages to markdown; used here for space backups + change tracking (re-export + git diff).

The dialect gap between them is the whole problem — and the motivation for a translation layer (small tool or agent skill) that rewrites cme exports into mark syntax so a backup can become an editable source of truth.

The dialect gap

concernmark (upload)cme (export)
metadata<!-- Space/Title/Parent --> commentsYAML front matter (title prepended as # H1 instead)
page links[text](ac:Page Title) by titlerelative [text](../Page.md) links
TOC:toc: macro (ac:toc)rendered #id-<page>-<heading> anchor list, or dropped
children:children: macro (live)nested page-link list, title-sorted (manual order lost)
alertsGitHub-style > [!NOTE] etc. → ac: macrosmacros → GitHub alerts, with a non-invertible map (below)
page propertiesraw ac:details tags or macros2-cell-row tables → YAML front matter keys

Round-trip stability (verified hands-on)

Alerts drift in one direction: cme maps info→IMPORTANT and note→WARNING, so mark’s [!NOTE] comes back as [!IMPORTANT]. Practical rule: prefer [!TIP] / [!WARNING] / [!CAUTION] / [!IMPORTANT]; avoid [!NOTE]. TOC/children/links change representation but keep semantics — expected diffs, not bugs.

Hard-won findings (all verified against tool source + compile runs):

  • mark frontmatter cannot feed macros. Template data reaches macros only via the macro/include comment YAML; properties: front matter goes to the content-properties API (invisible on page, lost on cme re-export). Single-source requires a preprocessor — and an unexpanded {{ key }} publishes literally, exit 0, no warning (guard with CI or a pre-commit grep).
  • Raw <ac:structured-macro> tags in the body are first-class, not a hack — mark ships a dedicated inline parser (ConfluenceTagParser, priority 199) so hand-written tags parse while markdown between them stays markdown. Winning pattern for page-properties tables: plain GFM table wrapped in hand-written ac:details tags — byte-identical compiled output to every macro-based approach, zero macro definitions, fully readable in GitHub/Obsidian.
  • Markdown inside a raw HTML block is not parsed (links/bold render literally) — the blank lines around the table are what make the GFM-inside-tags pattern work.
  • --minor-edit suppresses notifications but blocks label sync; --features= lists replace defaults (must re-add mermaid,mention).

The translation layer (idea)

Rewriter that turns a cme export into a mark source file, informed by the map above:

  • relative ../Page.md links → ac: title links; prepended # H1 → --title-from-h1 convention; TOC anchor list → :toc: macro; children list → :children:; alert re-mapping with the drift rule applied; escaped \<placeholder\> kept as-is (round-trips).
  • Shape: small deterministic tool (uv inline script) for the mechanical transforms + agent skill for judgment calls (which lists were live macros vs. hand-written content — cme can’t tell you).
  • Open: whether the round-trip converges (mark-source → publish → cme-export → translate → same mark-source). Convergence = backups become restorable sources of truth, closing the loop.

Related: Golden repo (docs-as-code rules) · mark (OSS) · confluence-markdown-exporter (OSS)