From 701c5ef2bad60b19e01a05d201bb5418838a5d85 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Fri, 8 May 2026 03:35:38 +0300 Subject: [PATCH] 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

from bare ac:toc, restoring the wrapper. - Strip inline-comment-marker wrappers and decorative s inside code spans where markdown can't represent them. - Normalize self-closing tags to "" and unify "/' with "/' 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,
before ,

-wrapped image runs, and bare text after headings. --- cmd/mdcx/verify.go | 47 +++++++ cmd/mdcx/verify_test.go | 4 +- confluence/renderer.go | 121 +++++++++++++++++- converter/xml2md.go | 269 +++++++++++++++++++++++++++++++++++++--- format/pretty.go | 33 ++++- format/pretty_test.go | 6 +- 6 files changed, 453 insertions(+), 27 deletions(-) diff --git a/cmd/mdcx/verify.go b/cmd/mdcx/verify.go index 3aa10a322d9d717a0d52a5efaffa5d2105472b09..075f3d1baf06c3ec48e30fc56329545842b6140a 100644 --- a/cmd/mdcx/verify.go +++ b/cmd/mdcx/verify.go @@ -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(`([^<]*)]*>([^<]*)`) // reAdjacentCode matches (directly adjacent), merging into one span. reAdjacentCode = regexp.MustCompile(``) + // reCodeBlock matches a ... span (non-greedy). + reCodeBlock = regexp.MustCompile(`(?s)(.*?)`) + // reInlineCommentTag matches an inline-comment-marker open or close tag. + reInlineCommentTag = regexp.MustCompile(`]*>`) + // reSpanOpen / reSpanClose match Confluence styling spans (no semantic + // span tags appear in stored Confluence XML — only style/class wrappers). + reSpanOpen = regexp.MustCompile(`]*)?>`) + reSpanClose = regexp.MustCompile(``) + // reTrailWSCloser strips trailing horizontal whitespace (incl. nbsp) + // before block-level closing tags. + reTrailWSCloser = regexp.MustCompile("[  \t]+()") + // reBrAtEndLi strips one or more
tags immediately before . + reBrAtEndLi = regexp.MustCompile(`(?:\s*\s*)+()`) + // rePImageOnly strips

...

wrappers when the contents are only + // elements (with optional whitespace), since round-trip wraps + // sequence images in a paragraph but original Confluence often doesn't. + rePImageOnly = regexp.MustCompile(`(?s)

\s*((?:.*?\s*)+)

`) + // reBareTextAfterHeading wraps bare text appearing directly between a + // heading close and a following block tag in

...

. Confluence accepts + // both forms but markdown round-trip always paragraphs it. + reBareTextAfterHeading = regexp.MustCompile(`(?s)()([^<]*?\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 wrappers inside 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("") : len(match)-len("")] + inner = reInlineCommentTag.ReplaceAllString(inner, "") + // Also collapse whitespace inside code (markdown code spans are single-line). + inner = strings.Join(strings.Fields(inner), " ") + return "" + inner + "" + }) // Unwrap inside (apply repeatedly for nested cases) for reSpanInCode.MatchString(xml) { xml = reSpanInCode.ReplaceAllString(xml, "${1}${2}") } // Merge adjacent elements xml = reAdjacentCode.ReplaceAllString(xml, "") + // Strip styling ... 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
tags inside
  • — they don't survive markdown. + xml = reBrAtEndLi.ReplaceAllString(xml, "$1") + // Strip

    ...

    wrap around runs of . + xml = rePImageOnly.ReplaceAllString(xml, "$1") + // Wrap bare text directly after headings in

    ...

    . + xml = reBareTextAfterHeading.ReplaceAllString(xml, "$1

    $2

    $3") return xml } diff --git a/cmd/mdcx/verify_test.go b/cmd/mdcx/verify_test.go index e76a0f8d62f2dab1fc8e1596141160e82f4edfff..6b5fca38ee510aef698420f8cb381085dce8173f 100644 --- a/cmd/mdcx/verify_test.go +++ b/cmd/mdcx/verify_test.go @@ -71,9 +71,9 @@ func TestNormalizeForVerify_SpanInsideCode(t *testing.T) { want: `plain`, }, { - name: "span outside code untouched", + name: "span outside code also stripped (decorative wrappers don't survive markdown)", input: `

    text

    `, - want: `

    text

    `, + want: `

    text

    `, }, } for _, tt := range tests { diff --git a/confluence/renderer.go b/confluence/renderer.go index 5277924b480c68439411e956e8a3a68d30ffcebf..46be42a849e33cecf2b4842a552cd0e6423da1ba 100644 --- a/confluence/renderer.go +++ b/confluence/renderer.go @@ -20,6 +20,11 @@ type Renderer struct { pendingTableAttrs string // stored from comment pendingCodeMacroID string // stored from comment pendingCodeAttrOrder string // stored from comment + pendingPanelName string // "info", "note", or "warning" — from 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

    wrapper + inBareBody bool // currently inside a body-bare blockquote, suppress

    } // 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

    + // wrapping inside . Skip the

    here so round-trip + // matches. + if r.inBareBody { + return ast.WalkContinue, nil + } if entering { w.WriteString("

    ") } 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(``) + name := r.pendingPanelName + if name == "" { + name = "info" + } + 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(`` + pval + ``) + } + } + w.WriteString(``) + r.inBareBody = r.pendingPanelBodyBare } else { w.WriteString(`` + "\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 "", true + // TOC macro (in-p variant: wrap in

    for original

    shapes) + case strings.HasPrefix(trimmed, "", then keep tokens starting with "param-". + body := strings.TrimSpace(comment) + body = strings.TrimPrefix(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 + `="` diff --git a/converter/xml2md.go b/converter/xml2md.go index ed8c3fa041307d666d0c1eff6cdc5be7dc3d6474..27a89576e023a0d6c4a77e12ba898bfdb94a9bd0 100644 --- a/converter/xml2md.go +++ b/converter/xml2md.go @@ -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

    , + // 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", macroID) + fmt.Fprintf(c.buf, "\n", marker, macroID) } else { - c.buf.WriteString("\n") + fmt.Fprintf(c.buf, "\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

    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, "\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("") + 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(">") + 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("") + } +} diff --git a/format/pretty.go b/format/pretty.go index 51c80d0e08e802a8af089ed6161992cb7ca1b5ff..e0e7a031b5ab79549a22298bb697a4df09331a49 100644 --- a/format/pretty.go +++ b/format/pretty.go @@ -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 +// "" 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 diff --git a/format/pretty_test.go b/format/pretty_test.go index bcc4c72f52bd928c82160bd7e9647e6b743df359..cbfa77d81bf9570ddbc24e8c22246fab68b09d4e 100644 --- a/format/pretty_test.go +++ b/format/pretty_test.go @@ -54,7 +54,8 @@ func TestPrettyXML_HeadingsWithInlineMarkup(t *testing.T) { func TestPrettyXML_SelfClosingBlock(t *testing.T) { input := `

    Before


    After

    ` result := PrettyXML(input, " ") - assert.Contains(t, result, "
    \n") + // Formatter normalizes self-closing tags to "" form. + assert.Contains(t, result, "
    \n") } func TestPrettyXML_Table(t *testing.T) { @@ -104,7 +105,8 @@ func TestPrettyXML_EmptyInput(t *testing.T) { func TestPrettyXML_UserAndAttachmentInline(t *testing.T) { input := `

    By see

    ` result := PrettyXML(input, " ") - assert.Contains(t, result, ``) + // Formatter normalizes self-closing tags to "" form. + assert.Contains(t, result, ``) } func TestPrettyXML_CustomIndent(t *testing.T) {