` swallows the rest of the document into a phantom
// table. md2xml's escaper turns the resulting `<` text back into `<`,
// closing the round-trip.
text = escapeMarkdownAngle(text)
c.buf.WriteString(text)
return
}
// Preserve literal HTML comments (e.g. a TOC macro marker that md2xml
// emitted inside a heading: ``).
// Without this, the comment node — being neither text nor element — falls
// through to walkChildren below and is silently dropped, collapsing
// `# ` to a bare `#` on round-trip.
if n.Type == html.CommentNode {
c.buf.WriteString("")
return
}
if n.Type != html.ElementNode {
c.walkChildren(n, depth)
return
}
tag := strings.ToLower(n.Data)
switch {
// Headings
case tag == "h1":
c.buf.WriteString("\n# ")
c.walkChildren(n, depth)
c.buf.WriteString("\n\n")
case tag == "h2":
c.buf.WriteString("\n## ")
c.walkChildren(n, depth)
c.buf.WriteString("\n\n")
case tag == "h3":
c.buf.WriteString("\n### ")
c.walkChildren(n, depth)
c.buf.WriteString("\n\n")
case tag == "h4":
c.buf.WriteString("\n#### ")
c.walkChildren(n, depth)
c.buf.WriteString("\n\n")
case tag == "h5":
c.buf.WriteString("\n##### ")
c.walkChildren(n, depth)
c.buf.WriteString("\n\n")
case tag == "h6":
c.buf.WriteString("\n###### ")
c.walkChildren(n, depth)
c.buf.WriteString("\n\n")
// Paragraphs
case tag == "p":
c.walkChildren(n, depth)
if !c.inListItem {
c.buf.WriteString("\n\n")
}
// Inline formatting
case tag == "strong", tag == "b":
c.writeEmphasis("**", n, depth)
case tag == "em", tag == "i":
c.writeEmphasis("*", n, depth)
case tag == "del", tag == "s":
c.writeEmphasis("~~", n, depth)
case tag == "code":
if !isPrevSiblingCode(n) {
c.buf.WriteString("`")
}
// Markdown code spans contain only literal text; drop inline-comment-marker
// and other element wrappers and emit just the text content.
c.buf.WriteString(collapseWhitespace(getTextContent(n)))
if !isNextSiblingCode(n) {
c.buf.WriteString("`")
}
// Links
case tag == "a":
href := getAttr(n, "href")
c.buf.WriteString("[")
c.walkChildren(n, depth)
c.buf.WriteString("](")
c.buf.WriteString(href)
c.buf.WriteString(")")
// Line break
case tag == "br":
c.buf.WriteString(" \n")
// Horizontal rule
case tag == "hr":
c.buf.WriteString("\n---\n\n")
// Lists
case tag == "ul":
c.pushList(2) // "- " marker → children indent 2 spaces
if c.listDepth == 1 {
c.buf.WriteString("\n")
}
c.walkChildren(n, depth)
c.popList()
if c.listDepth == 0 {
c.buf.WriteString("\n")
}
case tag == "ol":
c.pushList(3) // "1. " marker → children indent 3 spaces
if c.listDepth == 1 {
c.buf.WriteString("\n")
}
c.walkOL(n, depth)
c.popList()
if c.listDepth == 0 {
c.buf.WriteString("\n")
}
case tag == "li":
prev := c.inListItem
c.inListItem = true
// Check if this list item contains a task checkbox
if hasTaskStatus(n) {
// Task status handler will write the prefix, walkChildrenInline for text
c.walkChildrenInline(n, depth)
c.ensureSingleNewline()
} else {
c.buf.WriteString(c.itemIndent())
c.buf.WriteString("- ")
c.walkChildrenInline(n, depth)
c.ensureSingleNewline()
}
c.inListItem = prev
// Tables - convert to GFM table
case tag == "table":
c.renderTable(n, depth)
// Confluence macros - handled via ac:* namespace (parsed as ac-*)
// The HTML parser lowercases and handles colons differently.
// We need to handle both ac:structured-macro and the parsed form.
// Skip layout/structural elements, pass through children
case tag == "div", tag == "span", tag == "tbody", tag == "thead",
tag == "colgroup", tag == "col", tag == "content-wrapper":
c.walkChildren(n, depth)
// Handle Confluence-specific elements
default:
c.handleConfluenceElement(n, tag, depth)
}
}
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, "\n", sectionType)
c.walkChildren(n, depth)
c.buf.WriteString("\n")
case strings.Contains(tag, "ac:layout-cell") || strings.Contains(tag, "layout-cell"):
c.buf.WriteString("\n")
c.walkChildren(n, depth)
c.buf.WriteString("\n")
case tag == "ac:layout" || strings.Contains(tag, "layout") && !strings.Contains(tag, "layout-"):
c.buf.WriteString("\n")
c.walkChildren(n, depth)
c.buf.WriteString("\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, macroID)
case "info", "note", "warning":
c.renderPanelAsBlockquote(n, depth, macroName, macroID)
case "toc":
// Preserve TOC macro as HTML comment. If the parent is a ,
// 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 pageNode := findPageChild(n); pageNode != nil {
c.writePageLinkSpan(pageNode, n)
} 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.pushList(2)
c.walkChildren(n, depth)
c.popList()
case strings.Contains(tag, "task-body"):
c.walkChildren(n, depth)
c.ensureSingleNewline()
case strings.Contains(tag, "task-status"):
status := strings.TrimSpace(getTextContent(n))
indent := c.itemIndent()
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(escapeTablePipes(cell))
c.buf.WriteString(" |")
}
c.buf.WriteString("\n")
}
// escapeTablePipes escapes literal `|` characters in GFM table cell content.
// Per the GFM spec, `\|` is recognized as an escaped pipe at table-parse time
// and reduced to `|` before inline parsing — so escaped pipes survive even
// inside `code spans`. Pre-existing `\|` sequences are left alone to avoid
// double-escaping.
func escapeTablePipes(s string) string {
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
ch := s[i]
if ch == '\\' && i+1 < len(s) && s[i+1] == '|' {
b.WriteByte('\\')
b.WriteByte('|')
i++
continue
}
if ch == '|' {
b.WriteByte('\\')
b.WriteByte('|')
continue
}
b.WriteByte(ch)
}
return b.String()
}
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" {
c.buf.WriteString(c.itemIndent())
fmt.Fprintf(c.buf, "%d. ", idx)
c.walkChildrenInline(child, depth)
c.ensureSingleNewline()
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 != "" {
// Escape tag-like '<' so literal angle-bracket text in list items
// (e.g. `box.backup.start(
)`) is not re-parsed as raw HTML.
c.buf.WriteString(escapeMarkdownAngle(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)
//
if strings.Contains(tag, "url") {
v := getAttr(node, "ri:value")
if v == "" {
v = getAttr(node, "value")
}
if v != "" {
ref.url = v
return
}
}
//
if strings.Contains(tag, "attachment") {
f := getAttr(node, "ri:filename")
if f == "" {
f = getAttr(node, "filename")
}
if f != "" {
ref.filename = f
ref.isAttachment = true
return
}
}
}
for child := node.FirstChild; child != nil; child = child.NextSibling {
walk(child)
}
}
walk(n)
return ref
}
func (c *xmlConverter) hasUserChild(n *html.Node) bool {
for child := n.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode {
tag := strings.ToLower(child.Data)
if strings.Contains(tag, "user") {
return true
}
}
}
return false
}
// findPageChild returns the descendant of an , if any.
// ac:link wraps either an , , or other ri:* reference;
// page links need a special preservation path because they have no plain
// markdown equivalent.
func findPageChild(n *html.Node) *html.Node {
for child := n.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode {
tag := strings.ToLower(child.Data)
if strings.Contains(tag, "ri:page") {
return child
}
}
}
return nil
}
// writePageLinkSpan emits a placeholder that survives
// markdown round-trip. md2xml's convertRawSpan reverses this back into a full
// .
func (c *xmlConverter) writePageLinkSpan(page, link *html.Node) {
space := getAttr(page, "ri:space-key")
if space == "" {
space = getAttr(page, "space-key")
}
title := getAttr(page, "ri:content-title")
if title == "" {
title = getAttr(page, "content-title")
}
anchor := getAttr(link, "ac:anchor")
if anchor == "" {
anchor = getAttr(link, "anchor")
}
c.buf.WriteString(`")
c.buf.WriteString(body)
c.buf.WriteString("")
} else {
c.buf.WriteString("/>")
}
}
// pageLinkBody returns the plain-text body of an , dropping the
// reference itself. Confluence allows or
// raw text as the visible label of a page link.
func pageLinkBody(link *html.Node) string {
var b strings.Builder
for child := link.FirstChild; child != nil; child = child.NextSibling {
switch child.Type {
case html.TextNode:
b.WriteString(child.Data)
case html.ElementNode:
tag := strings.ToLower(child.Data)
if strings.Contains(tag, "ri:page") || strings.Contains(tag, "ri:user") {
continue
}
b.WriteString(getTextContent(child))
}
}
return strings.TrimSpace(b.String())
}
// Helper functions
func findNode(n *html.Node, tag string) *html.Node {
if n.Type == html.ElementNode && n.Data == tag {
return n
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
if found := findNode(child, tag); found != nil {
return found
}
}
return nil
}
func getAttr(n *html.Node, key string) string {
for _, attr := range n.Attr {
attrKey := attr.Key
if attr.Namespace != "" {
attrKey = attr.Namespace + ":" + attr.Key
}
if attrKey == key {
return attr.Val
}
}
return ""
}
// collapseWhitespace replaces runs of whitespace with a single space,
// preserving leading/trailing single space if original had whitespace there.
func collapseWhitespace(s string) string {
var buf strings.Builder
inWS := false
for _, r := range s {
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
if !inWS {
buf.WriteByte(' ')
inWS = true
}
} else {
buf.WriteRune(r)
inWS = false
}
}
return buf.String()
}
// escapeMarkdownAngle backslash-escapes a '<' that would start an HTML tag
// (i.e. is followed by a letter, '/', '!' or '?') so goldmark keeps it as
// literal text instead of raw HTML — otherwise a `` in body text
// swallows the rest of the document into a phantom table. The matching
// renderText unescape turns `\<` back into `<`, closing the round-trip.
// A lone '<' (as in `a < b` or `<=`) is left alone: goldmark already treats
// it as literal text, so escaping it would only add noise.
func escapeMarkdownAngle(s string) string {
if !strings.Contains(s, "<") {
return s
}
var buf strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '<' && (i == 0 || s[i-1] != '\\') && i+1 < len(s) && isTagNameStart(s[i+1]) {
buf.WriteString(`\<`)
continue
}
buf.WriteByte(s[i])
}
return buf.String()
}
// isTagNameStart reports whether b can follow '<' to begin an HTML tag,
// comment, or processing instruction that goldmark would parse as raw HTML.
func isTagNameStart(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || b == '/' || b == '!' || b == '?'
}
// hasTaskStatus checks if a node contains a task-status element.
func hasTaskStatus(n *html.Node) bool {
for child := n.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode {
tag := strings.ToLower(child.Data)
if strings.Contains(tag, "task-status") {
return true
}
}
}
return false
}
// getCDATAContent retrieves content from preprocessed CDATA sections.
// It looks for elements and unescapes their text.
func getCDATAContent(n *html.Node) string {
var buf bytes.Buffer
var walk func(*html.Node)
walk = func(node *html.Node) {
if node.Type == html.ElementNode && node.Data == "cdatacontent" {
text := getTextContent(node)
buf.WriteString(htmlpkg.UnescapeString(text))
return
}
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()
}
// extractAttrOrder returns a comma-separated list of short attribute names
// (e.g. "name,schema-version,macro-id") preserving the original order from the HTML node.
// The "ac:" prefix is stripped for brevity.
func extractAttrOrder(n *html.Node) string {
var names []string
for _, attr := range n.Attr {
key := attr.Key
if attr.Namespace != "" {
key = attr.Namespace + ":" + attr.Key
}
short := strings.TrimPrefix(key, "ac:")
names = append(names, short)
}
return strings.Join(names, ",")
}
// isNextSiblingCode checks if the next sibling is a element directly
// adjacent in the source. Any intervening text node (even whitespace) breaks
// adjacency, so `a b` does not merge.
func isNextSiblingCode(n *html.Node) bool {
s := n.NextSibling
if s == nil {
return false
}
return s.Type == html.ElementNode && strings.ToLower(s.Data) == "code"
}
// isPrevSiblingCode checks if the previous sibling is a element directly
// adjacent in the source. Any intervening text node (even whitespace) breaks
// adjacency.
func isPrevSiblingCode(n *html.Node) bool {
s := n.PrevSibling
if s == nil {
return false
}
return s.Type == html.ElementNode && strings.ToLower(s.Data) == "code"
}
// 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(">")
}
}