~bigbes/confluence-md-utilities

57679c6faff1d96169cd4a20334f4886792bdc91 — Eugene Blikh 2 months ago fb67f69
Rename module, add features, fix round-trip fidelity

- Rename module to sourcecraft.dev/bigbes/confluence-md-utilities
- Add fmt command with syntax highlighting (--color=auto/force/disabled)
- Add shell completions for all commands with file extension hints
- Preserve ac:layout/ac:layout-section/ac:layout-cell through round-trips
- Preserve ac:macro-id on code blocks and TOC macros
- Preserve table class/style attributes and colgroup column widths
- Preserve TOC macro as <!-- ac:toc --> comment
- Fix table cell rendering to preserve bold, italic, code, br, links
- Fix user references (ri:userkey) and attachment images (ri:attachment)
- Fix inline spacing between formatting elements in list items

false
M .gitignore => .gitignore +2 -0
@@ 16,3 16,5 @@ rfc-111.md
# OS
.DS_Store
Thumbs.db
rfc-111.xml.md
rfc-111.xml.roundtrip.xml

M README.md => README.md +69 -31
@@ 1,51 1,57 @@
# mdcx

Markdown to Confluence XML converter with bidirectional sync support for self-hosted Confluence Server/Data Center.
Markdown to Confluence XML converter with bidirectional sync for self-hosted Confluence Server/Data Center.

Converts Markdown to [Confluence storage format](https://confluence.atlassian.com/doc/confluence-storage-format-790796544.html) XML and back. Supports pulling pages from Confluence, editing locally as Markdown, and pushing changes back — with template-aware embedding that preserves metadata tables, changelogs, and inline comment markers.
Converts Markdown to [Confluence storage format](https://confluence.atlassian.com/doc/confluence-storage-format-790796544.html) XML and back.
Pull pages from Confluence, edit locally as Markdown, push changes back.
Template-aware embedding preserves metadata tables, changelogs, inline comment markers, user references, and attachment images through round-trips.

## Install

```bash
go install sourcecraft.dev/bigbes/markdown-to-confluence-xml@latest
go install sourcecraft.dev/bigbes/confluence-md-utilities@latest
```

The binary is named `mdcx`.
Binary name is `mdcx`.

## Commands

### `convert` — Markdown to Confluence XML
### convert

Markdown to Confluence XML.

```bash
mdcx convert input.md -o output.xml
cat input.md | mdcx convert > output.xml
```

### `embed` — Embed Markdown into a Confluence XML template
### embed

Converts Markdown and inserts it between marker comments in an existing Confluence document, preserving everything outside the markers (metadata table, TOC, changelog, etc.).
Convert Markdown and insert between marker comments in an existing Confluence document, preserving everything outside the markers (metadata table, TOC, changelog, etc.).

```bash
mdcx embed input.md --template template.xml -o output.xml
```

The template must contain marker comments:
Template must contain marker comments:

```xml
<!-- MD_CONTENT_START -->
<!-- MD_CONTENT_END -->
```

### `extract` — Extract Markdown from Confluence XML
### extract

Extracts content between markers and converts back to Markdown.
Extract content between markers from a Confluence XML document and convert back to Markdown.

```bash
mdcx extract input.xml -o output.md
mdcx extract input.xml --raw  # output raw Confluence XML
mdcx extract input.xml --raw    # raw Confluence XML, no conversion
```

### `pull` — Pull a page from Confluence
### pull

Fetch a page from Confluence and convert to Markdown.

```bash
mdcx pull "https://confluence.example.com/pages/viewpage.action?pageId=12345" -o page.md


@@ 53,29 59,47 @@ mdcx pull "https://confluence.example.com/display/TEAM/Page+Title" -o page.md
mdcx pull "https://confluence.example.com/display/TEAM/Page+Title" --raw -o page.xml
```

### `push` — Push Markdown to a Confluence page
### push

Convert local Markdown and update a Confluence page.

```bash
# Replace entire page body
mdcx push "https://confluence.example.com/display/TEAM/Page+Title" page.md
mdcx push "https://confluence.example.com/display/TEAM/Page" page.md

# Template mode: replace only content between markers
mdcx push "https://confluence.example.com/display/TEAM/Page+Title" page.md --template
# Template mode: only replace content between markers, keep everything else
mdcx push "https://confluence.example.com/display/TEAM/Page" page.md --template

# With version message
mdcx push "https://confluence.example.com/display/TEAM/Page+Title" page.md -m "Updated intro section"
mdcx push "https://confluence.example.com/display/TEAM/Page" page.md -m "Updated intro"
```

## Authentication
### fmt

Pretty-print Confluence storage XML with syntax highlighting.

```bash
mdcx fmt page.xml
mdcx fmt page.xml --color=force     # force colors even when piped
mdcx fmt page.xml --color=disabled  # no colors
mdcx fmt page.xml -o formatted.xml  # write to file (colors auto-disabled)
```

Colors: tags in blue/magenta/cyan by namespace, attributes in yellow, values in green, CDATA and comments in gray. Enabled by default when output is a terminal.

Block elements get their own lines with indentation. Short `<li>`, `<h1>`-`<h6>`, `<th>`, `<td>` stay on one line when content fits. Lines longer than 120 characters are wrapped at word boundaries (UTF-8 aware). CDATA content is preserved as-is.

For `pull` and `push`, provide a [Personal Access Token](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) via:
## Authentication

- `--token` flag, or
- `CONFLUENCE_TOKEN` environment variable
`pull` and `push` require a [Personal Access Token](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html):

```bash
# Via environment variable
export CONFLUENCE_TOKEN="your-token-here"
mdcx pull "https://confluence.example.com/display/TEAM/RFC-42" -o rfc.md

# Via flag
mdcx pull --token "your-token" "https://confluence.example.com/display/TEAM/RFC-42" -o rfc.md
```

## Typical workflow


@@ 83,16 107,29 @@ mdcx pull "https://confluence.example.com/display/TEAM/RFC-42" -o rfc.md
```bash
export CONFLUENCE_TOKEN="..."

# Pull page to local Markdown
# Pull
mdcx pull "https://confluence.example.com/display/TEAM/RFC-42" -o rfc.md

# Edit locally
# Edit
vim rfc.md

# Push back, preserving template structure
mdcx push "https://confluence.example.com/display/TEAM/RFC-42" rfc.md --template -m "Updated requirements"
```

## Shell completions

```bash
# Bash
source <(mdcx completion bash)

# Zsh (add to your .zshrc)
mdcx completion zsh > "${fpath[1]}/_mdcx"

# Fish
mdcx completion fish | source
```

## Supported elements

| Markdown | Confluence XML |


@@ 105,23 142,24 @@ mdcx push "https://confluence.example.com/display/TEAM/RFC-42" rfc.md --template
| Fenced code blocks | `<ac:structured-macro ac:name="code">` with CDATA |
| `- item` / `1. item` | `<ul>/<ol>` with `<li>` |
| Nested lists | Nested `<ul>/<ol>` inside `<li>` |
| `- [x] task` | `<ac:task-list>` / `<ac:task>` |
| `- [x] task` | `<ac:task-list>/<ac:task>` |
| `[text](url)` | `<a href="...">` |
| `![alt](url)` | `<ac:image><ri:url .../>` |
| `> blockquote` | `<ac:structured-macro ac:name="info">` (info panel) |
| `---` | `<hr/>` |
| GFM tables | `<table>` with `<th>/<td>` wrapped in `<p>` |
| Inline comment markers | Preserved via `<span data-inline-comment="ref">` in Markdown |

## Inline comment preservation
## Round-trip preservation

Confluence inline comments (`<ac:inline-comment-marker ac:ref="UUID">`) are preserved through round-trips. In Markdown they appear as:
Confluence-specific elements that have no Markdown equivalent are preserved through round-trips using HTML spans:

```html
<span data-inline-comment="b2f6ce98-4dc9-...">commented text</span>
```
| Confluence element | Markdown representation |
|---|---|
| `<ac:inline-comment-marker ac:ref="UUID">` | `<span data-inline-comment="UUID">text</span>` |
| `<ac:link><ri:user ri:userkey="KEY"/></ac:link>` | `<span data-user-key="KEY"/>` |
| `<ac:image><ri:attachment ri:filename="F"/></ac:image>` | `<span data-attachment="F"/>` |

This is converted back to proper `<ac:inline-comment-marker>` tags when pushing to Confluence.
These are converted back to proper Confluence tags on push.

## License


A cmd/completions.go => cmd/completions.go +55 -0
@@ 0,0 1,55 @@
package cmd

import (
	"github.com/spf13/cobra"
)

func init() {
	// File extension hints for positional args

	convertCmd.ValidArgsFunction = completeMarkdownFiles
	embedCmd.ValidArgsFunction = completeMarkdownFiles
	fmtCmd.ValidArgsFunction = completeXMLFiles
	extractCmd.ValidArgsFunction = completeXMLFiles
	pullCmd.ValidArgsFunction = cobra.NoFileCompletions
	pushCmd.ValidArgsFunction = completePushArgs

	// Flag file completions
	_ = convertCmd.RegisterFlagCompletionFunc("output", completeXMLFilesFlag)
	_ = embedCmd.RegisterFlagCompletionFunc("output", completeXMLFilesFlag)
	_ = embedCmd.RegisterFlagCompletionFunc("template", completeXMLFilesFlag)
	_ = extractCmd.RegisterFlagCompletionFunc("output", completeMarkdownFilesFlag)
	_ = fmtCmd.RegisterFlagCompletionFunc("output", completeXMLFilesFlag)
	_ = pullCmd.RegisterFlagCompletionFunc("output", completeMarkdownFilesFlag)

	// Marker flag completions
	for _, cmd := range []*cobra.Command{embedCmd, extractCmd, pushCmd} {
		_ = cmd.RegisterFlagCompletionFunc("marker-start", cobra.NoFileCompletions)
		_ = cmd.RegisterFlagCompletionFunc("marker-end", cobra.NoFileCompletions)
	}
}

func completeMarkdownFiles(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
	return []string{"md"}, cobra.ShellCompDirectiveFilterFileExt
}

func completeXMLFiles(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
	return []string{"xml"}, cobra.ShellCompDirectiveFilterFileExt
}

func completePushArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
	if len(args) == 0 {
		// First arg is URL — no file completion
		return nil, cobra.ShellCompDirectiveNoFileComp
	}
	// Second arg is input file
	return []string{"md"}, cobra.ShellCompDirectiveFilterFileExt
}

func completeMarkdownFilesFlag(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
	return []string{"md"}, cobra.ShellCompDirectiveFilterFileExt
}

func completeXMLFilesFlag(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
	return []string{"xml"}, cobra.ShellCompDirectiveFilterFileExt
}

M cmd/convert.go => cmd/convert.go +1 -1
@@ 7,7 7,7 @@ import (

	"github.com/spf13/cobra"

	"sourcecraft.dev/bigbes/markdown-to-confluence-xml/converter"
	"sourcecraft.dev/bigbes/confluence-md-utilities/converter"
)

var convertOutput string

M cmd/embed.go => cmd/embed.go +2 -2
@@ 7,8 7,8 @@ import (

	"github.com/spf13/cobra"

	"sourcecraft.dev/bigbes/markdown-to-confluence-xml/converter"
	"sourcecraft.dev/bigbes/markdown-to-confluence-xml/template"
	"sourcecraft.dev/bigbes/confluence-md-utilities/converter"
	"sourcecraft.dev/bigbes/confluence-md-utilities/template"
)

var (

M cmd/extract.go => cmd/extract.go +2 -2
@@ 7,8 7,8 @@ import (

	"github.com/spf13/cobra"

	"sourcecraft.dev/bigbes/markdown-to-confluence-xml/converter"
	"sourcecraft.dev/bigbes/markdown-to-confluence-xml/template"
	"sourcecraft.dev/bigbes/confluence-md-utilities/converter"
	"sourcecraft.dev/bigbes/confluence-md-utilities/template"
)

var (

M cmd/fmt.go => cmd/fmt.go +35 -1
@@ 7,12 7,13 @@ import (

	"github.com/spf13/cobra"

	"sourcecraft.dev/bigbes/markdown-to-confluence-xml/format"
	"sourcecraft.dev/bigbes/confluence-md-utilities/format"
)

var (
	fmtOutput string
	fmtIndent string
	fmtColor  string
)

var fmtCmd = &cobra.Command{


@@ 25,6 26,8 @@ get their own lines with indentation. Inline elements (strong, em, code,
a, ac:link, ac:image) stay on the same line. CDATA content inside code
blocks is preserved as-is.

Syntax highlighting is enabled by default when outputting to a terminal.

Reads from stdin if no file is specified.`,
	Args: cobra.MaximumNArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {


@@ 42,6 45,11 @@ Reads from stdin if no file is specified.`,

		result := format.PrettyXML(string(input), fmtIndent)

		useColor := resolveColor(fmtColor, fmtOutput)
		if useColor {
			result = format.Colorize(result)
		}

		if fmtOutput != "" {
			return os.WriteFile(fmtOutput, []byte(result), 0644)
		}


@@ 53,5 61,31 @@ Reads from stdin if no file is specified.`,
func init() {
	fmtCmd.Flags().StringVarP(&fmtOutput, "output", "o", "", "Output file (default: stdout)")
	fmtCmd.Flags().StringVar(&fmtIndent, "indent", "  ", "Indentation string (default: 2 spaces)")
	fmtCmd.Flags().StringVar(&fmtColor, "color", "auto", "Colorize output: auto, force, disabled")
	_ = fmtCmd.RegisterFlagCompletionFunc("color", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
		return []string{"auto", "force", "disabled"}, cobra.ShellCompDirectiveNoFileComp
	})
	rootCmd.AddCommand(fmtCmd)
}

func resolveColor(mode string, outputFile string) bool {
	switch mode {
	case "force":
		return true
	case "disabled":
		return false
	default: // "auto"
		if outputFile != "" {
			return false
		}
		return isTerminal(os.Stdout)
	}
}

func isTerminal(f *os.File) bool {
	stat, err := f.Stat()
	if err != nil {
		return false
	}
	return (stat.Mode() & os.ModeCharDevice) != 0
}

M cmd/pull.go => cmd/pull.go +2 -2
@@ 6,8 6,8 @@ import (

	"github.com/spf13/cobra"

	"sourcecraft.dev/bigbes/markdown-to-confluence-xml/api"
	"sourcecraft.dev/bigbes/markdown-to-confluence-xml/converter"
	"sourcecraft.dev/bigbes/confluence-md-utilities/api"
	"sourcecraft.dev/bigbes/confluence-md-utilities/converter"
)

var (

M cmd/push.go => cmd/push.go +3 -3
@@ 7,9 7,9 @@ import (

	"github.com/spf13/cobra"

	"sourcecraft.dev/bigbes/markdown-to-confluence-xml/api"
	"sourcecraft.dev/bigbes/markdown-to-confluence-xml/converter"
	"sourcecraft.dev/bigbes/markdown-to-confluence-xml/template"
	"sourcecraft.dev/bigbes/confluence-md-utilities/api"
	"sourcecraft.dev/bigbes/confluence-md-utilities/converter"
	"sourcecraft.dev/bigbes/confluence-md-utilities/template"
)

var (

M confluence/elements.go => confluence/elements.go +9 -1
@@ 3,11 3,19 @@ package confluence
// Confluence storage format macro helpers.

func CodeMacro(language string, body string) string {
	return CodeMacroWithID(language, body, "")
}

func CodeMacroWithID(language string, body string, macroID string) string {
	var lang string
	if language != "" {
		lang = `<ac:parameter ac:name="language">` + language + `</ac:parameter>`
	}
	return `<ac:structured-macro ac:name="code" ac:schema-version="1">` +
	tag := `<ac:structured-macro ac:name="code" ac:schema-version="1">`
	if macroID != "" {
		tag = `<ac:structured-macro ac:macro-id="` + macroID + `" ac:name="code" ac:schema-version="1">`
	}
	return tag +
		lang +
		`<ac:plain-text-body><![CDATA[` + escapeCDATA(body) + `]]></ac:plain-text-body>` +
		`</ac:structured-macro>`

M confluence/renderer.go => confluence/renderer.go +115 -4
@@ 17,6 17,8 @@ type Renderer struct {
	taskIDCounter      int
	inTaskBody         bool
	inlineCommentDepth int
	pendingTableAttrs  string // stored from <!-- table-attrs: ... --> comment
	pendingCodeMacroID string // stored from <!-- ac:code macro-id="..." --> comment
}

// NewRenderer creates a new Confluence storage format renderer.


@@ 131,7 133,8 @@ func (r *Renderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node a
	// Remove trailing newline from code content
	code := strings.TrimRight(buf.String(), "\n")

	w.WriteString(CodeMacro(language, code))
	w.WriteString(CodeMacroWithID(language, code, r.pendingCodeMacroID))
	r.pendingCodeMacroID = ""
	w.WriteString("\n")
	return ast.WalkSkipChildren, nil
}


@@ 241,12 244,80 @@ func (r *Renderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Nod
	for i := 0; i < n.Lines().Len(); i++ {
		line := n.Lines().At(i)
		raw := strings.TrimRight(string(line.Value(source)), "\n")
		w.WriteString(r.convertRawSpan(raw))
		w.WriteString("\n")
		if converted, ok := r.convertComment(raw); ok {
			w.WriteString(converted)
			w.WriteString("\n")
		} else {
			w.WriteString(r.convertRawSpan(raw))
			w.WriteString("\n")
		}
	}
	return ast.WalkSkipChildren, nil
}

// convertComment converts preserved HTML comments back to Confluence XML.
func (r *Renderer) convertComment(raw string) (string, bool) {
	trimmed := strings.TrimSpace(raw)

	switch {
	// Layout
	case trimmed == "<!-- ac:layout -->":
		return "<ac:layout>", true
	case trimmed == "<!-- /ac:layout -->":
		return "</ac:layout>", true
	case trimmed == "<!-- ac:layout-cell -->":
		return "<ac:layout-cell>", true
	case trimmed == "<!-- /ac:layout-cell -->":
		return "</ac:layout-cell>", true
	case trimmed == "<!-- /ac:layout-section -->":
		return "</ac:layout-section>", true
	case strings.HasPrefix(trimmed, "<!-- ac:layout-section"):
		sectionType := extractCommentAttr(trimmed, "type")
		if sectionType != "" {
			return `<ac:layout-section ac:type="` + sectionType + `">`, true
		}
		return "<ac:layout-section>", true

	// TOC macro
	case strings.HasPrefix(trimmed, "<!-- ac:toc"):
		macroID := extractCommentAttr(trimmed, "macro-id")
		if macroID != "" {
			return `<ac:structured-macro ac:macro-id="` + macroID + `" ac:name="toc" ac:schema-version="1"/>`, true
		}
		return `<ac:structured-macro ac:name="toc" ac:schema-version="1"/>`, true

	// Table attributes — store for next table
	case strings.HasPrefix(trimmed, "<!-- table-attrs:"):
		r.pendingTableAttrs = trimmed
		return "", true

	// Code macro-id — store for next code block
	case strings.HasPrefix(trimmed, "<!-- ac:code"):
		macroID := extractCommentAttr(trimmed, "macro-id")
		if macroID != "" {
			r.pendingCodeMacroID = macroID
		}
		return "", true
	}

	return "", false
}

// extractCommentAttr extracts a key="value" from an HTML comment.
func extractCommentAttr(comment, key string) string {
	search := key + `="`
	idx := strings.Index(comment, search)
	if idx == -1 {
		return ""
	}
	start := idx + len(search)
	end := strings.Index(comment[start:], `"`)
	if end == -1 {
		return ""
	}
	return comment[start : start+end]
}

func (r *Renderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if !entering {
		return ast.WalkContinue, nil


@@ 402,13 473,53 @@ func extractAttrValue(tag, attr string) string {

func (r *Renderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if entering {
		w.WriteString("<table>\n<tbody>\n")
		if r.pendingTableAttrs != "" {
			r.writeTableFromAttrs(w)
		} else {
			w.WriteString("<table>\n<tbody>\n")
		}
	} else {
		w.WriteString("</tbody>\n</table>\n")
	}
	return ast.WalkContinue, nil
}

// writeTableFromAttrs reconstructs <table> with class/style/colgroup from stored comment.
func (r *Renderer) writeTableFromAttrs(w util.BufWriter) {
	attrs := r.pendingTableAttrs
	r.pendingTableAttrs = ""

	cls := extractCommentAttr(attrs, "class")
	style := extractCommentAttr(attrs, "style")

	w.WriteString("<table")
	if cls != "" {
		fmt.Fprintf(w, ` class="%s"`, cls)
	}
	if style != "" {
		fmt.Fprintf(w, ` style="%s"`, style)
	}
	w.WriteString(">")

	// Extract colgroup
	colsStart := strings.Index(attrs, "cols=[")
	if colsStart != -1 {
		colsEnd := strings.Index(attrs[colsStart:], "]")
		if colsEnd != -1 {
			colsStr := attrs[colsStart+len("cols=[") : colsStart+colsEnd]
			colStyles := strings.Split(colsStr, "|")
			w.WriteString("<colgroup>")
			for _, cs := range colStyles {
				if cs != "" {
					fmt.Fprintf(w, `<col style="%s"/>`, cs)
				}
			}
			w.WriteString("</colgroup>")
		}
	}
	w.WriteString("\n<tbody>\n")
}

func (r *Renderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if entering {
		w.WriteString("<tr>\n")

M converter/md2xml.go => converter/md2xml.go +1 -1
@@ 8,7 8,7 @@ import (
	"github.com/yuin/goldmark/renderer"
	"github.com/yuin/goldmark/util"

	"sourcecraft.dev/bigbes/markdown-to-confluence-xml/confluence"
	"sourcecraft.dev/bigbes/confluence-md-utilities/confluence"
)

// MarkdownToConfluence converts Markdown source to Confluence storage format XML.

M converter/md2xml_test.go => converter/md2xml_test.go +66 -0
@@ 356,6 356,72 @@ func TestConfluenceToMarkdown_SpaceBetweenInlineElements(t *testing.T) {
	assert.Contains(t, result, "**Bold** then *italic* text")
}

// === Layout tests ===

func TestConfluenceToMarkdown_Layout(t *testing.T) {
	input := `<ac:layout><ac:layout-section ac:type="single"><ac:layout-cell><p>Content</p></ac:layout-cell></ac:layout-section></ac:layout>`
	result, err := ConfluenceToMarkdown(input)
	require.NoError(t, err)
	assert.Contains(t, result, "<!-- ac:layout -->")
	assert.Contains(t, result, `<!-- ac:layout-section type="single" -->`)
	assert.Contains(t, result, "<!-- ac:layout-cell -->")
	assert.Contains(t, result, "Content")
	assert.Contains(t, result, "<!-- /ac:layout-cell -->")
	assert.Contains(t, result, "<!-- /ac:layout-section -->")
	assert.Contains(t, result, "<!-- /ac:layout -->")
}

func TestConfluenceToMarkdown_LayoutTwoColumn(t *testing.T) {
	input := `<ac:layout><ac:layout-section ac:type="two_equal"><ac:layout-cell><p>Left</p></ac:layout-cell><ac:layout-cell><p>Right</p></ac:layout-cell></ac:layout-section></ac:layout>`
	result, err := ConfluenceToMarkdown(input)
	require.NoError(t, err)
	assert.Contains(t, result, `type="two_equal"`)
	assert.Contains(t, result, "Left")
	assert.Contains(t, result, "Right")
	assert.Equal(t, 2, strings.Count(result, "<!-- ac:layout-cell -->"))
	assert.Equal(t, 2, strings.Count(result, "<!-- /ac:layout-cell -->"))
}

func TestMarkdownToConfluence_Layout(t *testing.T) {
	input := `<!-- ac:layout -->
<!-- ac:layout-section type="single" -->
<!-- ac:layout-cell -->

Hello world

<!-- /ac:layout-cell -->
<!-- /ac:layout-section -->
<!-- /ac:layout -->
`
	result, err := MarkdownToConfluence([]byte(input))
	require.NoError(t, err)
	assert.Contains(t, result, "<ac:layout>")
	assert.Contains(t, result, `<ac:layout-section ac:type="single">`)
	assert.Contains(t, result, "<ac:layout-cell>")
	assert.Contains(t, result, "Hello world")
	assert.Contains(t, result, "</ac:layout-cell>")
	assert.Contains(t, result, "</ac:layout-section>")
	assert.Contains(t, result, "</ac:layout>")
}

func TestRoundTrip_Layout(t *testing.T) {
	xmlInput := `<ac:layout><ac:layout-section ac:type="two_equal"><ac:layout-cell><p>Left column</p></ac:layout-cell><ac:layout-cell><p>Right column</p></ac:layout-cell></ac:layout-section></ac:layout>`
	md, err := ConfluenceToMarkdown(xmlInput)
	require.NoError(t, err)

	xmlOutput, err := MarkdownToConfluence([]byte(md))
	require.NoError(t, err)

	assert.Contains(t, xmlOutput, "<ac:layout>")
	assert.Contains(t, xmlOutput, `ac:type="two_equal"`)
	assert.Contains(t, xmlOutput, "<ac:layout-cell>")
	assert.Contains(t, xmlOutput, "Left column")
	assert.Contains(t, xmlOutput, "Right column")
	assert.Contains(t, xmlOutput, "</ac:layout-cell>")
	assert.Contains(t, xmlOutput, "</ac:layout-section>")
	assert.Contains(t, xmlOutput, "</ac:layout>")
}

// === Round-trip tests ===

func TestRoundTrip_Basic(t *testing.T) {

M converter/xml2md.go => converter/xml2md.go +230 -7
@@ 234,15 234,38 @@ func (c *xmlConverter) walk(n *html.Node, depth int) {

func (c *xmlConverter) handleConfluenceElement(n *html.Node, tag string, depth int) {
	switch {
	// Layout elements — preserve as HTML comments for round-trip
	case strings.Contains(tag, "ac:layout-section") || strings.Contains(tag, "layout-section"):
		sectionType := getAttr(n, "ac:type")
		if sectionType == "" {
			sectionType = getAttr(n, "type")
		}
		fmt.Fprintf(c.buf, "<!-- ac:layout-section type=%q -->\n", sectionType)
		c.walkChildren(n, depth)
		c.buf.WriteString("<!-- /ac:layout-section -->\n")

	case strings.Contains(tag, "ac:layout-cell") || strings.Contains(tag, "layout-cell"):
		c.buf.WriteString("<!-- ac:layout-cell -->\n")
		c.walkChildren(n, depth)
		c.buf.WriteString("<!-- /ac:layout-cell -->\n")

	case tag == "ac:layout" || strings.Contains(tag, "layout") && !strings.Contains(tag, "layout-"):
		c.buf.WriteString("<!-- ac:layout -->\n")
		c.walkChildren(n, depth)
		c.buf.WriteString("<!-- /ac:layout -->\n")
	// Confluence structured macros (code blocks, panels, etc.)
	case strings.Contains(tag, "structured-macro") || strings.Contains(tag, "ac:structured-macro"):
		macroName := getAttr(n, "ac:name")
		if macroName == "" {
			macroName = getAttr(n, "name")
		}
		macroID := getAttr(n, "ac:macro-id")
		if macroID == "" {
			macroID = getAttr(n, "macro-id")
		}
		switch macroName {
		case "code":
			c.renderCodeMacro(n)
			c.renderCodeMacro(n, macroID)
		case "info":
			c.renderPanelAsBlockquote(n, depth)
		case "note":


@@ 250,7 273,12 @@ func (c *xmlConverter) handleConfluenceElement(n *html.Node, tag string, depth i
		case "warning":
			c.renderPanelAsBlockquote(n, depth)
		case "toc":
			// Skip TOC macros
			// Preserve TOC macro as HTML comment
			if macroID != "" {
				fmt.Fprintf(c.buf, "<!-- ac:toc macro-id=%q -->\n", macroID)
			} else {
				c.buf.WriteString("<!-- ac:toc -->\n")
			}
		default:
			c.walkChildren(n, depth)
		}


@@ 362,7 390,7 @@ func (c *xmlConverter) handleConfluenceElement(n *html.Node, tag string, depth i
	}
}

func (c *xmlConverter) renderCodeMacro(n *html.Node) {
func (c *xmlConverter) renderCodeMacro(n *html.Node, macroID string) {
	language := ""
	code := ""



@@ 390,7 418,12 @@ func (c *xmlConverter) renderCodeMacro(n *html.Node) {
	}
	walkMacro(n)

	c.buf.WriteString("\n```")
	if macroID != "" {
		fmt.Fprintf(c.buf, "\n<!-- ac:code macro-id=%q -->\n", macroID)
	} else {
		c.buf.WriteString("\n")
	}
	c.buf.WriteString("```")
	c.buf.WriteString(language)
	c.buf.WriteString("\n")
	c.buf.WriteString(code)


@@ 450,7 483,13 @@ func (c *xmlConverter) renderTable(n *html.Node, depth int) {
		return
	}

	c.buf.WriteString("\n")
	// Preserve table attributes and colgroup as HTML comment
	tableAttrs := extractTableAttrs(n)
	if tableAttrs != "" {
		fmt.Fprintf(c.buf, "\n<!-- table-attrs: %s -->\n", tableAttrs)
	} else {
		c.buf.WriteString("\n")
	}

	// If first row is a header
	isFirstRowHeader := len(rows) > 0 && rows[0].isHeader


@@ 546,6 585,41 @@ func (c *xmlConverter) walkChildrenInline(n *html.Node, depth int) {
	}
}

// extractTableAttrs extracts class, style, and colgroup info as a JSON-like string for preservation.
func extractTableAttrs(table *html.Node) string {
	var parts []string

	// Table class and style
	cls := getAttr(table, "class")
	style := getAttr(table, "style")
	if cls != "" {
		parts = append(parts, fmt.Sprintf("class=%q", cls))
	}
	if style != "" {
		parts = append(parts, fmt.Sprintf("style=%q", style))
	}

	// Colgroup
	var colWidths []string
	for child := table.FirstChild; child != nil; child = child.NextSibling {
		if child.Type == html.ElementNode && strings.ToLower(child.Data) == "colgroup" {
			for col := child.FirstChild; col != nil; col = col.NextSibling {
				if col.Type == html.ElementNode && strings.ToLower(col.Data) == "col" {
					colStyle := getAttr(col, "style")
					if colStyle != "" {
						colWidths = append(colWidths, colStyle)
					}
				}
			}
		}
	}
	if len(colWidths) > 0 {
		parts = append(parts, fmt.Sprintf("cols=[%s]", strings.Join(colWidths, "|")))
	}

	return strings.Join(parts, " ")
}

type tableRow struct {
	isHeader bool
	cells    []string


@@ 575,9 649,9 @@ func collectTableRows(table *html.Node) []tableRow {
						cellTag := strings.ToLower(child.Data)
						if cellTag == "th" {
							row.isHeader = true
							row.cells = append(row.cells, strings.TrimSpace(getTextContent(child)))
							row.cells = append(row.cells, strings.TrimSpace(renderCellMarkdown(child)))
						} else if cellTag == "td" {
							row.cells = append(row.cells, strings.TrimSpace(getTextContent(child)))
							row.cells = append(row.cells, strings.TrimSpace(renderCellMarkdown(child)))
						}
					}
				}


@@ 593,6 667,155 @@ func collectTableRows(table *html.Node) []tableRow {
	return rows
}

// renderCellMarkdown renders cell content to inline markdown, preserving
// formatting like bold, italic, code, links, br, and user references.
func renderCellMarkdown(cell *html.Node) string {
	var buf bytes.Buffer
	renderCellNode(&buf, cell)
	return buf.String()
}

func renderCellNode(buf *bytes.Buffer, n *html.Node) {
	for child := n.FirstChild; child != nil; child = child.NextSibling {
		switch child.Type {
		case html.TextNode:
			text := collapseWhitespace(child.Data)
			buf.WriteString(text)
		case html.ElementNode:
			tag := strings.ToLower(child.Data)
			switch {
			case tag == "strong" || tag == "b":
				buf.WriteString("**")
				renderCellNode(buf, child)
				buf.WriteString("**")
			case tag == "em" || tag == "i":
				buf.WriteString("*")
				renderCellNode(buf, child)
				buf.WriteString("*")
			case tag == "del" || tag == "s":
				buf.WriteString("~~")
				renderCellNode(buf, child)
				buf.WriteString("~~")
			case tag == "code":
				buf.WriteString("`")
				buf.WriteString(getTextContent(child))
				buf.WriteString("`")
			case tag == "a":
				href := getAttr(child, "href")
				buf.WriteString("[")
				renderCellNode(buf, child)
				buf.WriteString("](")
				buf.WriteString(href)
				buf.WriteString(")")
			case tag == "br":
				buf.WriteString("<br/>")
			case tag == "p":
				// Unwrap <p> inside cells
				renderCellNode(buf, child)
			case tag == "div":
				renderCellNode(buf, child)
			case strings.Contains(tag, "user"):
				userKey := getAttr(child, "ri:userkey")
				if userKey == "" {
					userKey = getAttr(child, "userkey")
				}
				if userKey != "" {
					fmt.Fprintf(buf, `<span data-user-key="%s"/>`, userKey)
				}
			case strings.Contains(tag, "ac:link"):
				renderCellNode(buf, child)
			case strings.Contains(tag, "image"):
				// Handle images in cells
				alt := getAttr(child, "ac:alt")
				if alt == "" {
					alt = getAttr(child, "alt")
				}
				var imgBuf bytes.Buffer
				c := &xmlConverter{buf: &imgBuf}
				ref := c.findImageRef(child)
				if ref.isAttachment {
					fmt.Fprintf(buf, `<span data-attachment="%s"`, ref.filename)
					if alt != "" {
						fmt.Fprintf(buf, ` data-alt="%s"`, alt)
					}
					buf.WriteString("/>")
				} else if ref.url != "" {
					buf.WriteString("![")
					buf.WriteString(alt)
					buf.WriteString("](")
					buf.WriteString(ref.url)
					buf.WriteString(")")
				}
			case strings.Contains(tag, "task-list"):
				renderCellTaskList(buf, child)
			case strings.Contains(tag, "emoticon"):
				name := getAttr(child, "ac:name")
				if name == "" {
					name = getAttr(child, "name")
				}
				switch name {
				case "plus":
					buf.WriteString("(+)")
				case "minus":
					buf.WriteString("(-)")
				case "question":
					buf.WriteString("(?)")
				case "tick":
					buf.WriteString("(v)")
				case "cross":
					buf.WriteString("(x)")
				}
			case strings.Contains(tag, "inline-comment-marker"):
				ref := getAttr(child, "ac:ref")
				if ref == "" {
					ref = getAttr(child, "ref")
				}
				if ref != "" {
					fmt.Fprintf(buf, `<span data-inline-comment="%s">`, ref)
					renderCellNode(buf, child)
					buf.WriteString("</span>")
				} else {
					renderCellNode(buf, child)
				}
			default:
				renderCellNode(buf, child)
			}
		}
	}
}

// renderCellTaskList renders a task list inside a table cell as inline markdown.
func renderCellTaskList(buf *bytes.Buffer, n *html.Node) {
	for child := n.FirstChild; child != nil; child = child.NextSibling {
		if child.Type != html.ElementNode {
			continue
		}
		tag := strings.ToLower(child.Data)
		if !strings.Contains(tag, "task") || strings.Contains(tag, "task-list") {
			continue
		}
		// This is an ac:task element
		status := ""
		var bodyContent string
		for tc := child.FirstChild; tc != nil; tc = tc.NextSibling {
			if tc.Type != html.ElementNode {
				continue
			}
			tcTag := strings.ToLower(tc.Data)
			if strings.Contains(tcTag, "task-status") {
				status = strings.TrimSpace(getTextContent(tc))
			} else if strings.Contains(tcTag, "task-body") {
				bodyContent = strings.TrimSpace(renderCellMarkdown(tc))
			}
		}
		check := "[ ]"
		if status == "complete" {
			check = "[x]"
		}
		fmt.Fprintf(buf, "- %s %s<br/>", check, bodyContent)
	}
}

type imageRef struct {
	url          string
	filename     string

A format/color.go => format/color.go +199 -0
@@ 0,0 1,199 @@
package format

import (
	"strings"
)

// ANSI color codes.
const (
	reset   = "\033[0m"
	red     = "\033[31m"
	green   = "\033[32m"
	yellow  = "\033[33m"
	blue    = "\033[34m"
	magenta = "\033[35m"
	cyan    = "\033[36m"
	gray    = "\033[90m"
)

// Colorize applies syntax highlighting to formatted Confluence XML.
func Colorize(input string) string {
	var buf strings.Builder
	i := 0
	for i < len(input) {
		if input[i] != '<' {
			// Text content — no color
			end := strings.Index(input[i:], "<")
			if end == -1 {
				buf.WriteString(input[i:])
				break
			}
			buf.WriteString(input[i : i+end])
			i += end
			continue
		}

		// CDATA
		if strings.HasPrefix(input[i:], "<![CDATA[") {
			end := strings.Index(input[i:], "]]>")
			if end == -1 {
				buf.WriteString(gray)
				buf.WriteString(input[i:])
				buf.WriteString(reset)
				break
			}
			buf.WriteString(gray)
			buf.WriteString(input[i : i+end+3])
			buf.WriteString(reset)
			i += end + 3
			continue
		}

		// Comment
		if strings.HasPrefix(input[i:], "<!--") {
			end := strings.Index(input[i:], "-->")
			if end == -1 {
				buf.WriteString(gray)
				buf.WriteString(input[i:])
				buf.WriteString(reset)
				break
			}
			buf.WriteString(gray)
			buf.WriteString(input[i : i+end+3])
			buf.WriteString(reset)
			i += end + 3
			continue
		}

		// XML tag
		end := strings.Index(input[i:], ">")
		if end == -1 {
			buf.WriteString(input[i:])
			break
		}
		tag := input[i : i+end+1]
		buf.WriteString(colorizeTag(tag))
		i += end + 1
	}
	return buf.String()
}

func colorizeTag(tag string) string {
	var buf strings.Builder

	// Closing tag: </name>
	if strings.HasPrefix(tag, "</") {
		name := tag[2 : len(tag)-1]
		buf.WriteString(gray)
		buf.WriteString("</")
		buf.WriteString(reset)
		buf.WriteString(tagNameColor(name))
		buf.WriteString(name)
		buf.WriteString(reset)
		buf.WriteString(gray)
		buf.WriteString(">")
		buf.WriteString(reset)
		return buf.String()
	}

	// Opening or self-closing tag
	selfClosing := strings.HasSuffix(tag, "/>")
	inner := tag[1:]
	if selfClosing {
		inner = inner[:len(inner)-2]
	} else {
		inner = inner[:len(inner)-1]
	}

	// Split tag name from attributes
	nameEnd := strings.IndexAny(inner, " \t\n")
	var name, attrs string
	if nameEnd == -1 {
		name = inner
	} else {
		name = inner[:nameEnd]
		attrs = inner[nameEnd:]
	}

	buf.WriteString(gray)
	buf.WriteString("<")
	buf.WriteString(reset)
	buf.WriteString(tagNameColor(name))
	buf.WriteString(name)
	buf.WriteString(reset)

	if attrs != "" {
		buf.WriteString(colorizeAttrs(attrs))
	}

	if selfClosing {
		buf.WriteString(gray)
		buf.WriteString("/>")
		buf.WriteString(reset)
	} else {
		buf.WriteString(gray)
		buf.WriteString(">")
		buf.WriteString(reset)
	}

	return buf.String()
}

func tagNameColor(name string) string {
	lower := strings.ToLower(name)
	switch {
	case strings.HasPrefix(lower, "ac:"):
		return magenta
	case strings.HasPrefix(lower, "ri:"):
		return cyan
	default:
		return blue
	}
}

func colorizeAttrs(attrs string) string {
	var buf strings.Builder
	rest := attrs
	for len(rest) > 0 {
		// Find next attribute: name="value" or name='value'
		eqIdx := strings.Index(rest, "=")
		if eqIdx == -1 {
			// No more attributes, just whitespace or text
			buf.WriteString(rest)
			break
		}

		// Everything before = is whitespace + attr name
		before := rest[:eqIdx]
		rest = rest[eqIdx+1:]

		// Split leading whitespace from attr name
		trimmed := strings.TrimLeft(before, " \t\n")
		ws := before[:len(before)-len(trimmed)]

		buf.WriteString(ws)
		buf.WriteString(yellow)
		buf.WriteString(trimmed)
		buf.WriteString(reset)
		buf.WriteString(gray)
		buf.WriteString("=")
		buf.WriteString(reset)

		// Read quoted value
		if len(rest) > 0 && (rest[0] == '"' || rest[0] == '\'') {
			quote := rest[0]
			endQ := strings.IndexByte(rest[1:], quote)
			if endQ == -1 {
				buf.WriteString(green)
				buf.WriteString(rest)
				buf.WriteString(reset)
				break
			}
			buf.WriteString(green)
			buf.WriteString(rest[:endQ+2])
			buf.WriteString(reset)
			rest = rest[endQ+2:]
		}
	}
	return buf.String()
}

A format/color_test.go => format/color_test.go +76 -0
@@ 0,0 1,76 @@
package format

import (
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestColorize_TagName(t *testing.T) {
	result := Colorize("<p>text</p>")
	// Should contain ANSI codes
	assert.Contains(t, result, "\033[")
	// Tag names should be colored, text should not have color wrapping
	assert.Contains(t, result, "text")
}

func TestColorize_ACNamespace(t *testing.T) {
	result := Colorize(`<ac:structured-macro ac:name="code">`)
	// ac: tags should use magenta
	assert.Contains(t, result, magenta+"ac:structured-macro")
}

func TestColorize_RINamespace(t *testing.T) {
	result := Colorize(`<ri:user ri:userkey="abc123"/>`)
	// ri: tags should use cyan
	assert.Contains(t, result, cyan+"ri:user")
}

func TestColorize_HTMLTag(t *testing.T) {
	result := Colorize("<p>hello</p>")
	// Standard HTML tags should use blue
	assert.Contains(t, result, blue+"p")
}

func TestColorize_Attributes(t *testing.T) {
	result := Colorize(`<ac:parameter ac:name="language">go</ac:parameter>`)
	// Attribute names should be yellow
	assert.Contains(t, result, yellow+"ac:name")
	// Attribute values should be green
	assert.Contains(t, result, green+`"language"`)
}

func TestColorize_CDATA(t *testing.T) {
	result := Colorize(`<![CDATA[code here]]>`)
	// CDATA should be gray
	assert.Contains(t, result, gray+"<![CDATA[code here]]>")
}

func TestColorize_Comment(t *testing.T) {
	result := Colorize(`<!-- comment -->`)
	assert.Contains(t, result, gray+"<!-- comment -->")
}

func TestColorize_PlainText(t *testing.T) {
	result := Colorize("just text")
	// No ANSI codes for plain text
	assert.False(t, strings.Contains(result, "\033["))
}

func TestColorize_SelfClosingTag(t *testing.T) {
	result := Colorize(`<hr/>`)
	assert.Contains(t, result, gray+"/>")
}

func TestColorize_ComplexDocument(t *testing.T) {
	input := `<ac:layout>
  <p>Hello <strong>world</strong></p>
  <!-- comment -->
</ac:layout>`
	result := Colorize(input)
	// Should not panic, should contain resets
	assert.True(t, strings.Count(result, reset) > 0)
	// Plain text "Hello " and "world" should be present without color wrapping
	assert.Contains(t, result, "Hello ")
}

M go.mod => go.mod +1 -1
@@ 1,4 1,4 @@
module sourcecraft.dev/bigbes/markdown-to-confluence-xml
module sourcecraft.dev/bigbes/confluence-md-utilities

go 1.26.1


M main.go => main.go +1 -1
@@ 1,6 1,6 @@
package main

import "sourcecraft.dev/bigbes/markdown-to-confluence-xml/cmd"
import "sourcecraft.dev/bigbes/confluence-md-utilities/cmd"

func main() {
	cmd.Execute()