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 " and literal apostrophes; goldmark/html
+// emits " and '. We normalize all to the Confluence form so verify
+// doesn't flag cosmetic differences.
+func normalizeEntities(s string) string {
+ s = strings.ReplaceAll(s, """, """)
+ s = strings.ReplaceAll(s, "'", "'")
+ s = strings.ReplaceAll(s, "'", "'")
+ 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) {