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="...">` |
| `` | `<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 => +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(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()