~bigbes/confluence-md-utilities

701c5ef2bad60b19e01a05d201bb5418838a5d85 — Eugene Blikh 2 months ago 66a3cf5
feat: improve round-trip fidelity for panels, tables, and verify

- Preserve panel name (info/note/warning), macro-id, parameters, and
  body-bare layout via HTML comment markers so blockquotes round-trip
  back to the original ac:structured-macro form.
- Serialize tables with complex cell content (nested lists, structured
  macros, row-header th, page links) as raw XML inside the markdown so
  GFM table limits don't lossily flatten them.
- Distinguish ac:toc inside <p> from bare ac:toc, restoring the wrapper.
- Strip inline-comment-marker wrappers and decorative <span>s inside
  code spans where markdown can't represent them.
- Normalize self-closing tags to "<tag />" and unify &#34;/&#39; with
  &quot;/' so the pretty printer doesn't introduce cosmetic diffs.
- Apply verify normalization to both input and round-trip output, and
  add rules for trailing whitespace, <br/> before </li>, <p>-wrapped
  image runs, and bare text after headings.
M cmd/mdcx/verify.go => cmd/mdcx/verify.go +47 -0
@@ 62,6 62,9 @@ Reads from stdin if no file is specified.`,
			return fmt.Errorf("markdown→xml: %w", err)
		}

		// Normalize the round-trip output too so equivalent-but-not-identical
		// forms compare equal on both sides.
		xmlRoundTrip = normalizeForVerify(xmlRoundTrip)
		formattedRoundTrip := format.PrettyXML(xmlRoundTrip, verifyIndent)

		if formatted == formattedRoundTrip {


@@ 101,18 104,62 @@ var (
	reSpanInCode = regexp.MustCompile(`(<code>[^<]*)<span[^>]*>([^<]*)</span>`)
	// reAdjacentCode matches </code><code> (directly adjacent), merging into one span.
	reAdjacentCode = regexp.MustCompile(`</code><code>`)
	// reCodeBlock matches a <code>...</code> span (non-greedy).
	reCodeBlock = regexp.MustCompile(`(?s)<code>(.*?)</code>`)
	// reInlineCommentTag matches an inline-comment-marker open or close tag.
	reInlineCommentTag = regexp.MustCompile(`</?ac:inline-comment-marker[^>]*>`)
	// reSpanOpen / reSpanClose match Confluence styling spans (no semantic
	// span tags appear in stored Confluence XML — only style/class wrappers).
	reSpanOpen  = regexp.MustCompile(`<span(?:\s+[^>]*)?>`)
	reSpanClose = regexp.MustCompile(`</span>`)
	// reTrailWSCloser strips trailing horizontal whitespace (incl. nbsp)
	// before block-level closing tags.
	reTrailWSCloser = regexp.MustCompile("[  \t]+(</(?:p|td|th|li|h[1-6])>)")
	// reBrAtEndLi strips one or more <br/> tags immediately before </li>.
	reBrAtEndLi = regexp.MustCompile(`(?:\s*<br\s*/?>\s*)+(</li>)`)
	// rePImageOnly strips <p>...</p> wrappers when the contents are only
	// <ac:image> elements (with optional whitespace), since round-trip wraps
	// sequence images in a paragraph but original Confluence often doesn't.
	rePImageOnly = regexp.MustCompile(`(?s)<p>\s*((?:<ac:image>.*?</ac:image>\s*)+)</p>`)
	// reBareTextAfterHeading wraps bare text appearing directly between a
	// heading close and a following block tag in <p>...</p>. Confluence accepts
	// both forms but markdown round-trip always paragraphs it.
	reBareTextAfterHeading = regexp.MustCompile(`(?s)(</h[1-6]>)([^<]*?\S[^<]*?)(\s*<(?:table|ul|ol|h[1-6]|ac:|p|hr))`)
)

// normalizeForVerify strips XML patterns that cannot survive a round-trip
// through Markdown, so verify compares only what the converter can preserve.
// This is applied to BOTH the original input and the round-trip output so that
// equivalent-but-not-identical forms compare equal.
func normalizeForVerify(xml string) string {
	xml = reEmptyParagraph.ReplaceAllString(xml, "")
	// Strip <ac:inline-comment-marker> wrappers inside <code> spans — markdown
	// code spans contain only literal text, so the marker can't survive round-trip.
	xml = reCodeBlock.ReplaceAllStringFunc(xml, func(match string) string {
		inner := match[len("<code>") : len(match)-len("</code>")]
		inner = reInlineCommentTag.ReplaceAllString(inner, "")
		// Also collapse whitespace inside code (markdown code spans are single-line).
		inner = strings.Join(strings.Fields(inner), " ")
		return "<code>" + inner + "</code>"
	})
	// Unwrap <span> inside <code> (apply repeatedly for nested cases)
	for reSpanInCode.MatchString(xml) {
		xml = reSpanInCode.ReplaceAllString(xml, "${1}${2}")
	}
	// Merge adjacent <code> elements
	xml = reAdjacentCode.ReplaceAllString(xml, "")
	// Strip styling <span ...>...</span> wrappers — they're decorative
	// (letter-spacing, color) and don't survive markdown round-trip.
	xml = reSpanOpen.ReplaceAllString(xml, "")
	xml = reSpanClose.ReplaceAllString(xml, "")
	// Strip trailing whitespace before block-level closing tags.
	xml = reTrailWSCloser.ReplaceAllString(xml, "$1")
	// Drop trailing <br/> tags inside <li> — they don't survive markdown.
	xml = reBrAtEndLi.ReplaceAllString(xml, "$1")
	// Strip <p>...</p> wrap around runs of <ac:image>.
	xml = rePImageOnly.ReplaceAllString(xml, "$1")
	// Wrap bare text directly after headings in <p>...</p>.
	xml = reBareTextAfterHeading.ReplaceAllString(xml, "$1<p>$2</p>$3")
	return xml
}


M cmd/mdcx/verify_test.go => cmd/mdcx/verify_test.go +2 -2
@@ 71,9 71,9 @@ func TestNormalizeForVerify_SpanInsideCode(t *testing.T) {
			want:  `<code>plain</code>`,
		},
		{
			name:  "span outside code untouched",
			name:  "span outside code also stripped (decorative wrappers don't survive markdown)",
			input: `<p><span>text</span></p>`,
			want:  `<p><span>text</span></p>`,
			want:  `<p>text</p>`,
		},
	}
	for _, tt := range tests {

M confluence/renderer.go => confluence/renderer.go +118 -3
@@ 20,6 20,11 @@ type Renderer struct {
	pendingTableAttrs  string // stored from <!-- table-attrs: ... --> comment
	pendingCodeMacroID    string // stored from <!-- ac:code macro-id="..." --> comment
	pendingCodeAttrOrder string // stored from <!-- ac:code ... attr-order="..." --> comment
	pendingPanelName     string // "info", "note", or "warning" — from <!-- ac:info ... --> comment
	pendingPanelMacroID  string // macro-id for the next blockquote-rendered panel
	pendingPanelParams   string // raw "param-key1=v1 param-key2=v2 ..." for the next panel
	pendingPanelBodyBare bool   // body-bare flag — emit body without <p> wrapper
	inBareBody           bool   // currently inside a body-bare blockquote, suppress <p>
}

// NewRenderer creates a new Confluence storage format renderer.


@@ 91,6 96,12 @@ func (r *Renderer) renderParagraph(w util.BufWriter, source []byte, node ast.Nod
		}
		return ast.WalkContinue, nil
	}
	// Inside a body-bare panel (info/note/warning), the original had no <p>
	// wrapping inside <ac:rich-text-body>. Skip the <p> here so round-trip
	// matches.
	if r.inBareBody {
		return ast.WalkContinue, nil
	}
	if entering {
		w.WriteString("<p>")
	} else {


@@ 166,9 177,40 @@ func (r *Renderer) renderThematicBreak(w util.BufWriter, source []byte, node ast

func (r *Renderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if entering {
		w.WriteString(`<ac:structured-macro ac:name="info" ac:schema-version="1"><ac:rich-text-body>`)
		name := r.pendingPanelName
		if name == "" {
			name = "info"
		}
		w.WriteString(`<ac:structured-macro ac:name="` + name + `" ac:schema-version="1"`)
		if r.pendingPanelMacroID != "" {
			w.WriteString(` ac:macro-id="` + r.pendingPanelMacroID + `"`)
		}
		w.WriteString(`>`)
		// Emit ac:parameter children from "param-name=value" tokens.
		if r.pendingPanelParams != "" {
			for _, p := range strings.Split(r.pendingPanelParams, " ") {
				if p == "" || !strings.HasPrefix(p, "param-") {
					continue
				}
				rest := strings.TrimPrefix(p, "param-")
				eq := strings.IndexByte(rest, '=')
				if eq < 0 {
					continue
				}
				pname := rest[:eq]
				pval := strings.Trim(rest[eq+1:], `"`)
				w.WriteString(`<ac:parameter ac:name="` + pname + `">` + pval + `</ac:parameter>`)
			}
		}
		w.WriteString(`<ac:rich-text-body>`)
		r.inBareBody = r.pendingPanelBodyBare
	} else {
		w.WriteString(`</ac:rich-text-body></ac:structured-macro>` + "\n")
		r.pendingPanelName = ""
		r.pendingPanelMacroID = ""
		r.pendingPanelParams = ""
		r.pendingPanelBodyBare = false
		r.inBareBody = false
	}
	return ast.WalkContinue, nil
}


@@ 280,13 322,25 @@ func (r *Renderer) convertComment(raw string) (string, bool) {
		}
		return "<ac:layout-section>", true

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

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

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


@@ 302,11 356,72 @@ func (r *Renderer) convertComment(raw string) (string, bool) {
		}
		r.pendingCodeAttrOrder = attrOrder
		return "", true

	// Panel macros (info/note/warning) — store metadata for next blockquote
	case strings.HasPrefix(trimmed, "<!-- ac:info") ||
		strings.HasPrefix(trimmed, "<!-- ac:note") ||
		strings.HasPrefix(trimmed, "<!-- ac:warning"):
		r.pendingPanelName = extractPanelName(trimmed)
		r.pendingPanelMacroID = extractCommentAttr(trimmed, "macro-id")
		// Collect param-* tokens
		r.pendingPanelParams = extractPanelParams(trimmed)
		r.pendingPanelBodyBare = strings.Contains(trimmed, " body-bare")
		return "", true
	}

	return "", false
}

// extractPanelName returns the panel kind ("info"/"note"/"warning") from a
// "<!-- ac:info ..." / "<!-- ac:note ..." / "<!-- ac:warning ..." marker.
func extractPanelName(comment string) string {
	for _, n := range []string{"info", "note", "warning"} {
		if strings.HasPrefix(comment, "<!-- ac:"+n) {
			return n
		}
	}
	return ""
}

// extractPanelParams collects all "param-key=value" tokens into a single
// space-separated string for renderBlockquote to consume.
func extractPanelParams(comment string) string {
	// Strip the leading "<!--" and trailing "-->", then keep tokens starting with "param-".
	body := strings.TrimSpace(comment)
	body = strings.TrimPrefix(body, "<!--")
	body = strings.TrimSuffix(body, "-->")
	body = strings.TrimSpace(body)
	var out []string
	// Walk tokens manually so we can keep "param-key=\"value with spaces\"" intact.
	i := 0
	for i < len(body) {
		// Skip whitespace.
		for i < len(body) && (body[i] == ' ' || body[i] == '\t') {
			i++
		}
		if i >= len(body) {
			break
		}
		// Read until next unquoted whitespace.
		start := i
		inQuote := false
		for i < len(body) {
			c := body[i]
			if c == '"' {
				inQuote = !inQuote
			} else if !inQuote && (c == ' ' || c == '\t') {
				break
			}
			i++
		}
		tok := body[start:i]
		if strings.HasPrefix(tok, "param-") {
			out = append(out, tok)
		}
	}
	return strings.Join(out, " ")
}

// extractCommentAttr extracts a key="value" from an HTML comment.
func extractCommentAttr(comment, key string) string {
	search := key + `="`

M converter/xml2md.go => converter/xml2md.go +253 -16
@@ 157,7 157,9 @@ func (c *xmlConverter) walk(n *html.Node, depth int) {
		if !isPrevSiblingCode(n) {
			c.buf.WriteString("`")
		}
		c.walkChildren(n, depth)
		// 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("`")
		}


@@ 270,18 272,19 @@ func (c *xmlConverter) handleConfluenceElement(n *html.Node, tag string, depth i
		switch macroName {
		case "code":
			c.renderCodeMacro(n, macroID)
		case "info":
			c.renderPanelAsBlockquote(n, depth)
		case "note":
			c.renderPanelAsBlockquote(n, depth)
		case "warning":
			c.renderPanelAsBlockquote(n, depth)
		case "info", "note", "warning":
			c.renderPanelAsBlockquote(n, depth, macroName, macroID)
		case "toc":
			// Preserve TOC macro as HTML comment
			// Preserve TOC macro as HTML comment. If the parent is a <p>,
			// emit a "-in-p" variant so the round-trip restores the <p> wrapper.
			marker := "ac:toc"
			if isParentTag(n, "p") {
				marker = "ac:toc-in-p"
			}
			if macroID != "" {
				fmt.Fprintf(c.buf, "<!-- ac:toc macro-id=%q -->\n", macroID)
				fmt.Fprintf(c.buf, "<!-- %s macro-id=%q -->\n", marker, macroID)
			} else {
				c.buf.WriteString("<!-- ac:toc -->\n")
				fmt.Fprintf(c.buf, "<!-- %s -->\n", marker)
			}
		default:
			c.walkChildren(n, depth)


@@ 444,18 447,40 @@ func (c *xmlConverter) renderCodeMacro(n *html.Node, macroID string) {
	c.buf.WriteString("```\n\n")
}

func (c *xmlConverter) renderPanelAsBlockquote(n *html.Node, depth int) {
	// Collect panel body content
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

	// Find rich-text-body and walk it
	var findBody func(*html.Node)
	findBody = func(node *html.Node) {
		if node.Type == html.ElementNode {
			tag := strings.ToLower(node.Data)
			if strings.Contains(tag, "rich-text-body") {
			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 <p> 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
			}


@@ 467,6 492,23 @@ func (c *xmlConverter) renderPanelAsBlockquote(n *html.Node, depth int) {
	findBody(n)

	c.buf = origBuf

	// Emit metadata marker so md2xml can restore name, macro-id, and parameters.
	var marker strings.Builder
	fmt.Fprintf(&marker, "<!-- ac:%s", panelName)
	if macroID != "" {
		fmt.Fprintf(&marker, " macro-id=%q", macroID)
	}
	for _, p := range params {
		marker.WriteString(" param-")
		marker.WriteString(p)
	}
	if !hasInnerP {
		marker.WriteString(" body-bare")
	}
	marker.WriteString(" -->\n")
	c.buf.WriteString(marker.String())

	text := strings.TrimSpace(bodyBuf.String())
	lines := strings.Split(text, "\n")
	for _, line := range lines {


@@ 478,6 520,18 @@ func (c *xmlConverter) renderPanelAsBlockquote(n *html.Node, depth int) {
}

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


@@ 523,6 577,93 @@ func (c *xmlConverter) renderTable(n *html.Node, depth int) {
	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 {


@@ 709,7 850,7 @@ func renderCellNode(buf *bytes.Buffer, n *html.Node) {
				buf.WriteString("~~")
			case tag == "code":
				buf.WriteString("`")
				buf.WriteString(getTextContent(child))
				buf.WriteString(collapseWhitespace(getTextContent(child)))
				buf.WriteString("`")
			case tag == "a":
				href := getAttr(child, "href")


@@ 1002,6 1143,15 @@ func isPrevSiblingCode(n *html.Node) bool {
	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)


@@ 1016,3 1166,90 @@ func getTextContent(n *html.Node) string {
	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("<![CDATA[")
			buf.WriteString(htmlpkg.UnescapeString(getTextContent(n)))
			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. <td></td>).
			buf.WriteString("></")
			buf.WriteString(n.Data)
			buf.WriteString(">")
			return
		}
		buf.WriteString(">")
		// Inside <code> 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(">")
	}
}

M format/pretty.go => format/pretty.go +29 -4
@@ 127,14 127,15 @@ func PrettyXML(input string, indent string) string {

		case tokenSelfClose:
			tagName := tok.tagName()
			raw := normalizeSelfClose(tok.raw)
			if inPre > 0 {
				buf.WriteString(tok.raw)
				buf.WriteString(raw)
				i++
				continue
			}
			if blockTags[tagName] || tagName == "hr" || tagName == "col" {
				ensureIndentedLine(&buf, level, indent, &atLineStart)
				buf.WriteString(tok.raw)
				buf.WriteString(raw)
				buf.WriteString("\n")
				atLineStart = true
			} else {


@@ 142,7 143,7 @@ func PrettyXML(input string, indent string) string {
					writeIndentPrefix(&buf, level, indent)
					atLineStart = false
				}
				buf.WriteString(tok.raw)
				buf.WriteString(raw)
			}

		case tokenText:


@@ 183,7 184,7 @@ func PrettyXML(input string, indent string) string {
		i++
	}

	result := buf.String()
	result := normalizeEntities(buf.String())
	// Post-process: clean up lines and wrap long ones
	lines := strings.Split(result, "\n")
	var final []string


@@ 255,6 256,8 @@ func tryInlineBlock(tokens []token, tagName string) (string, int) {
				return "", 0
			}
			inner.WriteString(tok.raw)
		case tokenSelfClose:
			inner.WriteString(normalizeSelfClose(tok.raw))
		default:
			inner.WriteString(tok.raw)
		}


@@ 384,6 387,28 @@ func writeIndentPrefix(buf *strings.Builder, level int, indent string) {
	}
}

// normalizeSelfClose ensures all self-closing tags use the canonical
// "<tag ... />" form (with a single space before the slash).
func normalizeSelfClose(raw string) string {
	if !strings.HasSuffix(raw, "/>") {
		return raw
	}
	inner := strings.TrimSuffix(raw, "/>")
	inner = strings.TrimRight(inner, " \t")
	return inner + " />"
}

// normalizeEntities maps interchangeable entity escapes onto a canonical form.
// Confluence storage produces &quot; and literal apostrophes; goldmark/html
// emits &#34; and &#39;. We normalize all to the Confluence form so verify
// doesn't flag cosmetic differences.
func normalizeEntities(s string) string {
	s = strings.ReplaceAll(s, "&#34;", "&quot;")
	s = strings.ReplaceAll(s, "&#39;", "'")
	s = strings.ReplaceAll(s, "&apos;", "'")
	return s
}

func collapseWS(s string) string {
	var buf strings.Builder
	inWS := false

M format/pretty_test.go => format/pretty_test.go +4 -2
@@ 54,7 54,8 @@ func TestPrettyXML_HeadingsWithInlineMarkup(t *testing.T) {
func TestPrettyXML_SelfClosingBlock(t *testing.T) {
	input := `<p>Before</p><hr/><p>After</p>`
	result := PrettyXML(input, "  ")
	assert.Contains(t, result, "<hr/>\n")
	// Formatter normalizes self-closing tags to "<tag />" form.
	assert.Contains(t, result, "<hr />\n")
}

func TestPrettyXML_Table(t *testing.T) {


@@ 104,7 105,8 @@ func TestPrettyXML_EmptyInput(t *testing.T) {
func TestPrettyXML_UserAndAttachmentInline(t *testing.T) {
	input := `<p>By <ac:link><ri:user ri:userkey="abc123"/></ac:link> see <ac:image><ri:attachment ri:filename="img.png"/></ac:image></p>`
	result := PrettyXML(input, "  ")
	assert.Contains(t, result, `<ac:link><ri:user ri:userkey="abc123"/></ac:link>`)
	// Formatter normalizes self-closing tags to "<tag />" form.
	assert.Contains(t, result, `<ac:link><ri:user ri:userkey="abc123" /></ac:link>`)
}

func TestPrettyXML_CustomIndent(t *testing.T) {