package converter import ( "bytes" "fmt" htmlpkg "html" "strings" "golang.org/x/net/html" ) // ConfluenceToMarkdown converts Confluence storage format XML to Markdown. func ConfluenceToMarkdown(source string) (string, error) { // Preprocess: extract CDATA content and replace with escaped text, // because x/net/html doesn't handle CDATA sections. preprocessed := preprocessCDATA(source) // Wrap in a root element so the HTML parser handles it correctly. wrapped := "
, // emit a "-in-p" variant so the round-trip restores the
wrapper. marker := "ac:toc" if isParentTag(n, "p") { marker = "ac:toc-in-p" } if macroID != "" { fmt.Fprintf(c.buf, "\n", marker, macroID) } else { fmt.Fprintf(c.buf, "\n", marker) } default: c.walkChildren(n, depth) } // Confluence images case strings.Contains(tag, "image") || strings.Contains(tag, "ac:image"): alt := getAttr(n, "ac:alt") if alt == "" { alt = getAttr(n, "alt") } imgRef := c.findImageRef(n) if imgRef.isAttachment { // Preserve attachment reference as round-trippable HTML fmt.Fprintf(c.buf, `") } else { c.buf.WriteString(" c.buf.WriteString(imgRef.url) c.buf.WriteString(")") } // Confluence links (user mentions, page links) case strings.Contains(tag, "ac:link"): if c.hasUserChild(n) { c.walkChildren(n, depth) } else { c.walkChildren(n, depth) } // Confluence emoticons case strings.Contains(tag, "emoticon") || strings.Contains(tag, "ac:emoticon"): name := getAttr(n, "ac:name") if name == "" { name = getAttr(n, "name") } switch name { case "plus": c.buf.WriteString("(+)") case "minus": c.buf.WriteString("(-)") case "question": c.buf.WriteString("(?)") case "tick": c.buf.WriteString("(v)") case "cross": c.buf.WriteString("(x)") } // Confluence task lists case strings.Contains(tag, "task-list"): c.listDepth++ c.walkChildren(n, depth) c.listDepth-- case strings.Contains(tag, "task-body"): c.walkChildren(n, depth) c.buf.WriteString("\n") case strings.Contains(tag, "task-status"): status := strings.TrimSpace(getTextContent(n)) indent := strings.Repeat(" ", max(0, c.listDepth-1)) if status == "complete" { c.buf.WriteString(indent + "- [x] ") } else { c.buf.WriteString(indent + "- [ ] ") } case strings.Contains(tag, "task-id"): // Skip task IDs case strings.Contains(tag, "task") && !strings.Contains(tag, "task-"): c.walkChildren(n, depth) // Confluence inline comment markers — preserve as span with data attribute case strings.Contains(tag, "inline-comment-marker"): ref := getAttr(n, "ac:ref") if ref == "" { ref = getAttr(n, "ref") } if ref != "" { fmt.Fprintf(c.buf, ``, ref) c.walkChildren(n, depth) c.buf.WriteString("") } else { c.walkChildren(n, depth) } // User references — preserve as round-trippable HTML span case strings.Contains(tag, "ri:user"): userKey := getAttr(n, "ri:userkey") if userKey == "" { userKey = getAttr(n, "userkey") } if userKey != "" { fmt.Fprintf(c.buf, ``, userKey) } // Time elements case tag == "time": datetime := getAttr(n, "datetime") if datetime != "" { c.buf.WriteString(datetime) } // Fallback: just walk children default: c.walkChildren(n, depth) } } func (c *xmlConverter) renderCodeMacro(n *html.Node, macroID string) { language := "" code := "" // Walk children to find parameters and body var walkMacro func(*html.Node) walkMacro = func(node *html.Node) { if node.Type == html.ElementNode { tag := strings.ToLower(node.Data) if strings.Contains(tag, "parameter") || strings.Contains(tag, "ac:parameter") { name := getAttr(node, "ac:name") if name == "" { name = getAttr(node, "name") } if name == "language" { language = getTextContent(node) } } if strings.Contains(tag, "plain-text-body") || strings.Contains(tag, "ac:plain-text-body") { code = getCDATAContent(node) } } for child := node.FirstChild; child != nil; child = child.NextSibling { walkMacro(child) } } walkMacro(n) // Extract original attribute order for round-trip fidelity attrOrder := extractAttrOrder(n) if macroID != "" { if attrOrder != "" { fmt.Fprintf(c.buf, "\n\n", macroID, attrOrder) } else { fmt.Fprintf(c.buf, "\n\n", macroID) } } else { c.buf.WriteString("\n") } c.buf.WriteString("```") c.buf.WriteString(language) c.buf.WriteString("\n") c.buf.WriteString(code) if !strings.HasSuffix(code, "\n") { c.buf.WriteString("\n") } c.buf.WriteString("```\n\n") } func (c *xmlConverter) renderPanelAsBlockquote(n *html.Node, depth int, panelName string, macroID string) { // Collect panel parameters and body. Parameters are preserved via an HTML // comment marker so md2xml's blockquote renderer can restore them. var params []string var hasInnerP bool var findBody func(*html.Node) var bodyBuf bytes.Buffer origBuf := c.buf c.buf = &bodyBuf findBody = func(node *html.Node) { if node.Type == html.ElementNode { tag := strings.ToLower(node.Data) switch { case strings.Contains(tag, "ac:parameter") || strings.Contains(tag, "parameter"): name := getAttr(node, "ac:name") if name == "" { name = getAttr(node, "name") } val := getTextContent(node) if name != "" { params = append(params, fmt.Sprintf("%s=%q", name, val)) } return case strings.Contains(tag, "rich-text-body"): // Track whether body has an explicit
wrapper, so we can
// reproduce it on round-trip.
for ch := node.FirstChild; ch != nil; ch = ch.NextSibling {
if ch.Type == html.ElementNode && strings.ToLower(ch.Data) == "p" {
hasInnerP = true
break
}
}
c.walkChildren(node, depth)
return
}
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
findBody(child)
}
}
findBody(n)
c.buf = origBuf
// Emit metadata marker so md2xml can restore name, macro-id, and parameters.
var marker strings.Builder
fmt.Fprintf(&marker, "\n")
c.buf.WriteString(marker.String())
text := strings.TrimSpace(bodyBuf.String())
lines := strings.Split(text, "\n")
for _, line := range lines {
c.buf.WriteString("> ")
c.buf.WriteString(line)
c.buf.WriteString("\n")
}
c.buf.WriteString("\n")
}
func (c *xmlConverter) renderTable(n *html.Node, depth int) {
// If the table contains structures that don't survive a GFM round-trip
// (block content in cells, row-header th cells, bullet lists in cells,
// structured macros in cells), serialize the entire table as raw XML
// inside a markdown HTML block. md2xml passes HTML blocks through
// verbatim, which preserves the structure exactly.
if tableNeedsRawSerialize(n) {
c.buf.WriteString("\n")
serializeNodeXML(c.buf, n)
c.buf.WriteString("\n\n")
return
}
rows := collectTableRows(n)
if len(rows) == 0 {
return
}
// Determine column count
cols := 0
for _, row := range rows {
if len(row.cells) > cols {
cols = len(row.cells)
}
}
if cols == 0 {
return
}
// Preserve table attributes and colgroup as HTML comment
tableAttrs := extractTableAttrs(n)
if tableAttrs != "" {
fmt.Fprintf(c.buf, "\n\n", tableAttrs)
} else {
c.buf.WriteString("\n")
}
// If first row is a header
isFirstRowHeader := len(rows) > 0 && rows[0].isHeader
startIdx := 0
if isFirstRowHeader {
c.writeTableRow(rows[0].cells, cols)
c.writeTableSep(cols)
startIdx = 1
} else {
// Write empty header and separator
empty := make([]string, cols)
c.writeTableRow(empty, cols)
c.writeTableSep(cols)
}
for i := startIdx; i < len(rows); i++ {
c.writeTableRow(rows[i].cells, cols)
}
c.buf.WriteString("\n")
}
// tableNeedsRawSerialize reports whether the table contains structures that
// can't survive round-trip through GFM markdown table syntax. Triggers for
// raw-serialize: row-header th cells (after row 0), or block content
// (lists, structured macros, task-lists, content-wrappers) inside cells.
func tableNeedsRawSerialize(table *html.Node) bool {
rowIdx := -1
var complex bool
var walk func(*html.Node)
walk = func(n *html.Node) {
if complex || n.Type != html.ElementNode {
if !complex {
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
return
}
tag := strings.ToLower(n.Data)
switch tag {
case "tr":
rowIdx++
case "th":
if rowIdx > 0 {
complex = true
return
}
case "td":
if cellHasComplexContent(n) {
complex = true
return
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(table)
return complex
}
// cellHasComplexContent reports whether the cell contains block-level structures
// that can't be represented as inline markdown in a GFM table cell.
func cellHasComplexContent(cell *html.Node) bool {
var found bool
var walk func(*html.Node)
walk = func(n *html.Node) {
if found || n.Type != html.ElementNode {
if !found {
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
return
}
tag := strings.ToLower(n.Data)
switch {
case tag == "ul" || tag == "ol":
found = true
return
case strings.Contains(tag, "structured-macro"):
found = true
return
case strings.Contains(tag, "task-list"):
found = true
return
case strings.Contains(tag, "ac:link"):
// ac:link with ri:page (page link) needs full XML; ri:user is fine inline.
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode {
ct := strings.ToLower(c.Data)
if strings.Contains(ct, "ri:page") || strings.Contains(ct, "page") {
if !strings.Contains(ct, "ri:user") {
found = true
return
}
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(cell)
return found
}
func (c *xmlConverter) writeTableRow(cells []string, cols int) {
c.buf.WriteString("|")
for i := range cols {
cell := ""
if i < len(cells) {
cell = cells[i]
}
c.buf.WriteString(" ")
c.buf.WriteString(cell)
c.buf.WriteString(" |")
}
c.buf.WriteString("\n")
}
func (c *xmlConverter) writeTableSep(cols int) {
c.buf.WriteString("|")
for range cols {
c.buf.WriteString("---|")
}
c.buf.WriteString("\n")
}
func (c *xmlConverter) walkOL(n *html.Node, depth int) {
idx := 1
for child := n.FirstChild; child != nil; child = child.NextSibling {
if child.Type != html.ElementNode {
continue
}
tag := strings.ToLower(child.Data)
if tag == "li" {
indent := strings.Repeat(" ", max(0, c.listDepth-1))
c.buf.WriteString(indent)
fmt.Fprintf(c.buf, "%d. ", idx)
c.walkChildrenInline(child, depth)
c.buf.WriteString("\n")
idx++
}
}
}
func (c *xmlConverter) walkChildrenInline(n *html.Node, depth int) {
for child := n.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.TextNode {
// Collapse whitespace but preserve a single space between inline elements
text := collapseWhitespace(child.Data)
// Only trim leading space if this is the very first child
if child == n.FirstChild {
text = strings.TrimLeft(text, " ")
}
// Only trim trailing space if this is the very last child
if child.NextSibling == nil {
text = strings.TrimRight(text, " ")
}
if text != "" {
c.buf.WriteString(text)
}
continue
}
if child.Type == html.ElementNode {
tag := strings.ToLower(child.Data)
switch {
case tag == "p":
c.walkChildrenInline(child, depth)
case tag == "ul", tag == "ol":
c.buf.WriteString("\n")
c.walk(child, depth)
default:
c.walk(child, depth)
}
}
}
}
// 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
}
func collectTableRows(table *html.Node) []tableRow {
var rows []tableRow
var walk func(*html.Node, bool)
walk = func(n *html.Node, inHeader bool) {
if n.Type == html.ElementNode {
tag := strings.ToLower(n.Data)
switch tag {
case "thead":
for child := n.FirstChild; child != nil; child = child.NextSibling {
walk(child, true)
}
return
case "tbody":
for child := n.FirstChild; child != nil; child = child.NextSibling {
walk(child, false)
}
return
case "tr":
row := tableRow{isHeader: inHeader}
for child := n.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode {
cellTag := strings.ToLower(child.Data)
if cellTag == "th" {
row.isHeader = true
row.cells = append(row.cells, strings.TrimSpace(renderCellMarkdown(child)))
} else if cellTag == "td" {
row.cells = append(row.cells, strings.TrimSpace(renderCellMarkdown(child)))
}
}
}
rows = append(rows, row)
return
}
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
walk(child, inHeader)
}
}
walk(table, false)
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(collapseWhitespace(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("
")
case tag == "p":
// Unwrap
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, ``, 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, `")
} 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, ``, ref)
renderCellNode(buf, child)
buf.WriteString("")
} 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
", check, bodyContent)
}
}
type imageRef struct {
url string
filename string
isAttachment bool
}
func (c *xmlConverter) findImageRef(n *html.Node) imageRef {
var ref imageRef
var walk func(*html.Node)
walk = func(node *html.Node) {
if node.Type == html.ElementNode {
tag := strings.ToLower(node.Data)
// element.
func isNextSiblingCode(n *html.Node) bool {
for s := n.NextSibling; s != nil; s = s.NextSibling {
if s.Type == html.TextNode && strings.TrimSpace(s.Data) == "" {
continue
}
return s.Type == html.ElementNode && strings.ToLower(s.Data) == "code"
}
return false
}
// isPrevSiblingCode checks if the previous non-whitespace sibling is a element.
func isPrevSiblingCode(n *html.Node) bool {
for s := n.PrevSibling; s != nil; s = s.PrevSibling {
if s.Type == html.TextNode && strings.TrimSpace(s.Data) == "" {
continue
}
return s.Type == html.ElementNode && strings.ToLower(s.Data) == "code"
}
return false
}
// isParentTag reports whether n's parent is an element with the given tag name.
func isParentTag(n *html.Node, tag string) bool {
p := n.Parent
if p == nil || p.Type != html.ElementNode {
return false
}
return strings.EqualFold(p.Data, tag)
}
func getTextContent(n *html.Node) string {
var buf bytes.Buffer
var walk func(*html.Node)
walk = func(node *html.Node) {
if node.Type == html.TextNode {
buf.WriteString(node.Data)
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
walk(child)
}
}
walk(n)
return buf.String()
}
// xmlVoidTags lists Confluence/HTML elements that are always self-closing.
var xmlVoidTags = map[string]bool{
"br": true,
"hr": true,
"col": true,
"img": true,
"ri:user": true,
"ri:url": true,
"ri:attachment": true,
"ri:page": true,
"ri:space": true,
"ri:blog-post": true,
"ri:shortcut": true,
"time": true,
}
// serializeNodeXML writes an html.Node back to Confluence-style XML.
// Used for round-tripping table fragments that can't be represented in
// GFM markdown — they survive as raw HTML blocks in the markdown output.
func serializeNodeXML(buf *bytes.Buffer, n *html.Node) {
switch n.Type {
case html.TextNode:
buf.WriteString(htmlpkg.EscapeString(n.Data))
case html.ElementNode:
// Restore CDATA from preprocessing.
if n.Data == "cdatacontent" {
buf.WriteString("")
return
}
buf.WriteString("<")
buf.WriteString(n.Data)
for _, attr := range n.Attr {
buf.WriteString(" ")
if attr.Namespace != "" {
buf.WriteString(attr.Namespace)
buf.WriteString(":")
}
buf.WriteString(attr.Key)
buf.WriteString(`="`)
buf.WriteString(htmlpkg.EscapeString(attr.Val))
buf.WriteString(`"`)
}
// Confluence storage format treats certain elements as always self-closing
// (br, hr, col, ri:user, ri:attachment, time, ...). The HTML parser doesn't
// know about the Confluence-specific ones and may attach trailing text or
// elements as children. We emit "/>" anyway, then re-emit those misplaced
// children as siblings so they survive the round-trip.
if xmlVoidTags[n.Data] {
buf.WriteString(" />")
for c := n.FirstChild; c != nil; c = c.NextSibling {
serializeNodeXML(buf, c)
}
return
}
hasChild := n.FirstChild != nil
if !hasChild {
// Empty non-void element: emit open+close to preserve semantics
// (e.g. ).
buf.WriteString(">")
buf.WriteString(n.Data)
buf.WriteString(">")
return
}
buf.WriteString(">")
// Inside spans, drop inline-comment-marker wrappers since they
// can't be preserved through markdown code spans (also normalized away
// in normalizeForVerify).
inCode := strings.ToLower(n.Data) == "code"
for child := n.FirstChild; child != nil; child = child.NextSibling {
if inCode && child.Type == html.ElementNode &&
strings.Contains(strings.ToLower(child.Data), "inline-comment-marker") {
// Inline children of the marker, skip the marker wrapper itself.
for gc := child.FirstChild; gc != nil; gc = gc.NextSibling {
serializeNodeXML(buf, gc)
}
continue
}
serializeNodeXML(buf, child)
}
buf.WriteString("")
buf.WriteString(n.Data)
buf.WriteString(">")
}
}