Confluence's 'Export to Word' is a lie, so I wrote a Go tool strangers now file bugs against
At work I had to drag a pile of legacy IT controls, written years ago and scattered across many Confluence pages in several spaces, into line with industry frameworks like NIST and the Cloud Security Alliance's Cloud Controls Matrix (CCM). Step one of any job like that is "get the text out of Confluence". Step one is where it went wrong: Confluence's Export to Word button hands you a .doc file that is not a Word document.
So I wrote confluence2md: one binary, no runtime to install, pandoc hidden inside, clean Markdown out. Today it became Apache 2.0, because a stranger asked nicely. This is the build log.
Go · zero Go dependencies · pandoc 3.6.4 embedded · GitHub · Releases
The itch
The controls lived in Confluence at a global media and entertainment conglomerate, page after page, space after space. To rewrite them against NIST and CCM I needed all of them as plain text in one place, so I could diff them, search them, and work through them against the frameworks with an LLM's help. Page by page, Confluence offers two exports: PDF, which is useless for editing, and Word.
Word it was. Then I tried to open the first export in anything that wasn't Word.
What "Export to Word" actually exports
Open one of those .doc files in a text editor and you are looking at an email:
Date: Wed, 7 Jan 2026 01:29:00 +0000 (UTC)
Subject: Exported From Confluence
MIME-Version: 1.0
Content-Type: multipart/related;
boundary="----=_Part_123_456789.123456789"
------=_Part_123_456789.123456789
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<html>...
It is a MIME multipart message: the HTML of the page in a text/html part, quoted-printable encoded, with a .doc extension stapled on. Word opens it because Word understands MHTML. LibreOffice, Google Docs, python-docx and mammoth do not; they look for a binary Word header and find an email. Atlassian's own community forum has carried this complaint for over a decade.
Rename the export to .mht and a browser opens it fine. That is the whole secret, and also why nothing downstream of Confluence can read it: every converter checks the bytes, not the extension.
Why Go, for a first Go project
I had never written Go before this. I picked it for one reason: the people who needed this tool sit at locked-down corporate laptops. No Python, no Node, no admin rights to install pandoc, no appetite for a README that starts with "first, set up a virtual environment". Go gives you a single static binary per platform, cross-compiled from one machine with CGO_ENABLED=0, and that was the whole pitch.
The only real dependency is pandoc, which does the heavy lifting of HTML to Markdown. So the release binaries carry pandoc inside them, embedded with go:embed, one platform-specific file each. On first run the tool extracts pandoc to the user's cache directory (~/.cache/confluence2md/pandoc-3.6.4/ on Linux, the equivalents on macOS and Windows) and reuses it from there. The price is size: 23 to 40 MB compressed per download, 85 to 100 MB unpacked. The Go side itself has zero dependencies; go.mod is two lines.
Pandoc is GPL, which matters once you bundle it. It ships as a separate executable that confluence2md runs as a child process, never linked, with the GPL text and a notice in every archive. Hold that thought; it comes back in the licensing section.
Who wrote it: Cursor, then Claude Code
Blunt version: I typed very little of the Go.
The first version happened in September 2025, a few evenings in Cursor, soon after I started playing with it. It parsed my exports, it ran on my colleagues' laptops, it lived in a private repo and it was not pretty. It did the job for the controls work and then sat there.
The public repo starts on 9 January 2026, and that is Claude Code's work. Every one of the 27 commits in it as of today carries a Co-Authored-By: Claude trailer. My job was the steering: what it should do, which real exports it had to survive, what "done" meant, and saying no.
Claude Code kept its own tracker in the repo, .claude/remediation-progress.md, five phases ticked off in order:
- Tests. Coverage on the main package went from 49.2% to 85.1%;
main()was split intoparseFlags()andrun()so it could be tested at all. - Project hygiene. CHANGELOG, SECURITY.md, a code of conduct, issue and pull-request templates.
- Code quality.
IsConfluenceMIME()learned to return an error instead of swallowing it; magic numbers became named constants; the regex soup got comments. - CI. golangci-lint, CodeQL, Codecov,
go test -race. - Fuzzing. Six fuzz targets over the MIME parser and the Markdown post-processing.
The fuzzers earned their keep the same day they were added.
Removing a tag can create a tag. balanceDetailsTags() deleted surplus </details> closers by counting once and decrementing. Feed it <</details>/details> and deleting the inner closer leaves a brand-new </details> behind. Fix: recount after every removal.
"text file busy", in CI only. With go test -race, every package runs as its own binary, and all of them raced to extract pandoc to the same cache path, then execute a file another process was still writing. Fix: write to a temp file named with the PID, sync, rename atomically, and retry the executable check with small delays instead of deleting what someone else just created.
Release day was six releases
v0.1.0 went up at 16:51 UTC on 9 January. v0.3.1 went up at 20:12. Six releases in three and a half hours, each one fixing something the previous one taught me:
- macOS refused to run the binary ("Apple could not verify"), so the README grew the
xattr -d com.apple.quarantineworkaround, twice, until it was right. - The module path was wrong for the Go proxy, so
go installdid not work. - The CI lint job ran
go vetbefore downloading the pandoc binaries it embeds, so it failed on a project that built fine locally. - A logo, then a shinier logo.
GoReleaser does the actual work: tag, push, and GitHub Actions builds macOS (Intel and Apple Silicon), Linux and Windows archives with checksums. When a release costs one git tag, you ship your mistakes faster too. v0.4.0 followed the next day with the hardening above, and v0.4.1 in the early hours of today with the fuzzers, the race fix and a new licence.
The licence wobble
Day one shipped with a dual licence: free for personal and open-source use, "commercial licence available", a Buy Me a Coffee badge, and a prompt after every conversion asking for a star or a coffee. I had vague ideas about companies paying for it.
On the evening of 12 January, bettercallcaleb opened the repo's first issue from outside: "Commercial PoC license exemption". Polite, detailed, three options laid out (an evaluation-only carve-out, a dual licence, a small-use exception) and an offer to draft the wording. They wanted to run an internal proof of concept and the licence said no.
Reading it, I realised I did not want to be a licence desk. The first commercial user of a document converter is not a procurement department with a budget; it is one person who needs permission to try it. At one in the morning I switched the whole thing to Apache 2.0, with an "enterprise support" section still in the README. By the afternoon that was gone too, commit message and all: "no longer needed in how I want this licensed." Then I replied: go ahead and use it, anyone can.
Apache 2.0 covers my code. Pandoc stays GPL and stays a separate executable, which is why every archive still carries the third-party notices. The coffee link stays too; nobody has to click it.
If a tool is useful enough that a stranger writes you three paragraphs about licensing, the licence is the thing in the way. Nobody buys a commercial licence for a converter; they ask nicely, or they move on.
What this did to the certification plan
My last post here was a fifteen-week study plan for AI-102. I studied for two months and never booked the exam. Then Cursor showed up in August, this tool happened in September, and by the time Claude Code had finished hardening it the question had changed. The cert would have taught me Azure's AI services. What I actually needed was to learn how to build with AI agents, and this tool taught me that in a few evenings. It was the wrong bet for me. The long version is a post of its own.
What is next
Open items in the repo's own notes: image extraction (--extract-images), an --output-dir flag, and recursive directory scanning. The obvious missing piece is pulling pages straight from Confluence by URL instead of clicking Export on each one. That is not started. (Update, October 2026: it is now, and not by me. See the postscript.)
Postscript, October 2026
Added on 31 October 2026, nine months after the post above. The title promises strangers filing bugs; here they are.
The second stranger arrived seventeen days after the licence change. wj3171868 reported blocks of raw HTML left in the Markdown wherever a page used a macro the tool did not know, a Change History macro in their case, and asked a fair question: drop unhandled macros, flatten them to text, or leave the HTML? I replied two and a half weeks later asking for a sanitised sample. None came, and the issue sat.
The third, julianneswinoga, opened with "Cool repo!" on 2 June and attached a one-page export: a table whose cell contained a bullet list came out as HTML, because pandoc's GFM writer cannot put block content in a pipe-table cell and gives up on the whole table. I saw it on 27 June. My reply, verbatim: "let me see if I can ask claude code to fix it."
It did, on 2 July, and v0.5.0 went out on the 3rd:
- In-cell lists are flattened to
• itemsegments before pandoc runs, joined by a sentinel that the post-processor turns back into<br>, so the table survives as a Markdown table and the bullets survive as line breaks inside the cell. - Confluence puts
<th>cells inside<tbody>with no<thead>, so pandoc was inventing an empty header row and demoting the real one. The first all-<th>row is now promoted to a proper header. That fix also caught a latent bug: the regex that stripped attributes from<th ...>matched<thead>as well. - Unhandled macro wrappers are stripped and their text kept, which closed the January issue too, built against a representative export because I never got the real file.
Each fix was written test-first, with the failing test confirmed before the change, and each shipped with new fuzz seeds; 45 seconds of fuzzing per target surfaced no crashers. The co-author trailers had moved on from Opus 4.5 in January to Opus 4.8 in July.
Then, on 22 September, mtaul28 opened a pull request: 1,349 lines adding exactly the missing piece from the section above, pulling Markdown straight from Confluence with a personal access token and a page URL, recursively through child pages into a mirrored folder tree. It sat for a month, partly on me and partly because CI never ran on it: the repo requires a one-time approval for first-time contributors and the run expired before I got to it. As I write this I have the branch checked out and am running the suite, lint and end-to-end conversions against live Confluence Server and Cloud sites before a proper review. It will be the first feature in the tool that someone else wrote. A month from pull request to review is the honest cost of open-sourcing something: the tool moves at the speed of my evenings.
The numbers, as of 31 October 2026:
| What | Count |
|---|---|
| Stars | 48 (30 of them in the first month; the first ten within about three hours of v0.1.0) |
| Forks | 3 |
| Releases | 9, six of them on release day |
| Release downloads | 257 (158 of them v0.4.1) |
| Go, excluding tests | 1,395 lines |
| Tests | 4,016 lines, 122 test functions, 6 fuzz targets |
| Go dependencies | 0 |
| Commits | 38, every non-merge commit co-authored by Claude Code |
If you have a Confluence export that comes out wrong, attach it to an issue. That is how both bugs got fixed.