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
| concern | mark (upload) | cme (export) |
|---|---|---|
| metadata | <!-- Space/Title/Parent --> comments | YAML front matter (title prepended as # H1 instead) |
| page links | [text](ac:Page Title) by title | relative [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) |
| alerts | GitHub-style > [!NOTE] etc. → ac: macros | macros → GitHub alerts, with a non-invertible map (below) |
| page properties | raw ac:details tags or macros | 2-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-writtenac:detailstags — 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-editsuppresses notifications but blocks label sync;--features=lists replace defaults (must re-addmermaid,mention).
The translation layer (idea)
Rewriter that turns a cme export into a mark source file, informed by the map above:
- relative
../Page.mdlinks →ac:title links; prepended# H1→--title-from-h1convention; 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)