~bigbes/confluence-md-utilities

ref: 442bf5982d1e46a2737804b2f30a9b4895e9ac02 confluence-md-utilities/CLAUDE.md -rw-r--r-- 5.8 KiB
442bf598 — Eugene Blikh fix: round-trip fidelity for tables, page links, and nested lists 2 months ago

#CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

#Build & Test

just build          # build binary to ./mdcx
just install        # go install to $GOPATH/bin
just test           # go test ./...
just test-v         # verbose tests
go test ./converter/... -run TestRoundTrip  # run specific tests

#Architecture

mdcx converts Markdown ↔ Confluence storage-format XML with round-trip fidelity. The binary lives at cmd/mdcx/.

#Package graph

cmd/mdcx/      CLI (cobra commands, package main)
  ├─ converter  Core bidirectional conversion
  ├─ api        Confluence REST client (pull/push)
  ├─ template   Marker-based embed/extract
  └─ format     XML pretty-printer + ANSI colorizer

converter/
  ├─ md2xml.go  goldmark parser → confluence.Renderer → XML
  └─ xml2md.go  golang.org/x/net/html walker → Markdown

confluence/
  ├─ renderer.go  goldmark NodeRenderer (registered at priority 100)
  └─ elements.go  Macro builders: CodeMacro, InfoPanel, etc.

#Key design decisions

Bidirectional round-trip preservation — Confluence elements with no Markdown equivalent (inline comments, user mentions, attachment images, layout sections) survive round-trips via HTML <span data-*> attributes and <!-- comment --> markers. The md→xml renderer reconstructs proper ac:*/ri:* tags from these.

CDATA preprocessingxml2md.go replaces <![CDATA[...]]> with <cdatacontent> fake elements before parsing, because golang.org/x/net/html doesn't handle CDATA. The reverse direction uses confluence.escapeCDATA() to split ]]> sequences.

Renderer stateconfluence.Renderer tracks taskIDCounter, inTaskBody, inlineCommentDepth, pageLinkDepth, and pending attributes from HTML comments. This state is per-conversion and must remain consistent across the AST walk.

Template markers<!-- MD_CONTENT_START --> / <!-- MD_CONTENT_END --> delimit the editable region in Confluence pages. The push --template flow: fetch page → replace between markers → update with version increment.

Comment sidecar — Confluence comment threads live outside the page body. pull --with-comments calls /rest/api/content/{id}/child/comment (paginated, with children.comment expanded two levels deep) and writes them to <output>.comments.json. Inline comments link back to body markers via the marker_ref UUID, which equals the data-inline-comment="..." attribute the renderer emits. Comment round-trip (push) is not implemented.

#Semantic mapping (non-obvious)

  • Markdown blockquotes → Confluence info panel macro (not <blockquote>)
  • Task lists detected by checkbox presence, rendered as <ac:task-list>
  • Code blocks → <ac:structured-macro ac:name="code"> with CDATA body
  • XML namespaces (ac:, ri:) are string-constructed, not from an XML library

#Round-trip gotchas (with regression tests)

These are subtle traps the round-trip has been bitten by; touching the related code without updating the tests in converter/md2xml_test.go will likely re-break a real document. Each item names the test that pins it.

  • GFM tables and | inside <code> — a literal | in a cell is a column separator, even inside backticks. xml2md.writeTableRow runs every cell through escapeTablePipes which writes \|; the GFM parser strips the backslash at table-parse time before inline parsing, so the pipe survives inside the resulting code span. Tests: TestRoundTrip_TablePipeInCode, TestEscapeTablePipes_NoDoubleEscape.
  • Adjacent <code> merge must not span whitespace<code>a</code><code>b</code> (no gap) merges into one code span on the way to MD; <code>a</code> <code>b</code> (whitespace text node between) must stay as two. isPrevSiblingCode / isNextSiblingCode therefore look at the immediate neighbour only and refuse to skip whitespace text nodes. Test: TestConfluenceToMarkdown_AdjacentCodeMerge (case "whitespace-separated …").
  • Page links have no markdown form<ac:link><ri:page ri:space-key="…" ri:content-title="…"/></ac:link> is preserved as a <span data-page-link="1" …> placeholder. Goldmark cannot parse <ac:link> as raw inline HTML because of the : in the tag name, so a span wrapper is required. The reverse path lives in confluence.Renderer.convertRawSpan; closing </span> consults pageLinkDepth to emit </ac:link> at the right point. Test: TestRoundTrip_PageLink.
  • Nested-list indent depends on the parent list type — CommonMark places content of 1. at column 4 and - at column 3, so a nested item must be indented by ≥ the parent marker's width. xmlConverter.listIndents is a stack of per-level widths (3 for <ol>, 2 for <ul> / task-list); itemIndent() sums all ancestor contributions. Using a fixed 2*(depth-1) collapses nested ordered lists into siblings. Tests: TestRoundTrip_NestedOrderedList, TestRoundTrip_NestedMixedList, TestRoundTrip_DoubleNestedUnorderedList.
  • Tight lists are one stray \n away from loose — a blank line anywhere in a list turns it loose, which makes goldmark wrap every <li> in <p>. ensureSingleNewline() writes \n only when the buffer doesn't already end in one, preventing the double newline that appeared when a <li>'s body ended with a nested list (which itself terminated in \n). Always use it instead of c.buf.WriteString("\n") after walking list-item content.

#Verifying real documents

mdcx verify <file.xml> formats the input, round-trips it through xml2md → md2xml, formats the output, and diffs them. The justfile in ~/data/1 runs this against five real Confluence pages — exercise it after any change to converter or renderer code, since the unit tests can miss multi-feature interactions (e.g. a task list inside a <div class="content-wrapper"> inside a table cell).