~bigbes/confluence-md-utilities

442bf5982d1e46a2737804b2f30a9b4895e9ac02 — Eugene Blikh 2 months ago 701c5ef
fix: round-trip fidelity for tables, page links, and nested lists

Four real-document failures surfaced via `mdcx verify`:

- Pipes inside `<code>` in GFM table cells were treated as column
  separators, splitting cells. `writeTableRow` now escapes `|` as `\|`
  per the GFM spec; the parser strips the backslash before inline
  parsing so the pipe survives inside the resulting code span.
- Two `<code>` elements separated by whitespace text were being merged
  because `isPrev/NextSiblingCode` skipped whitespace nodes. Adjacency
  is now strict.
- `<ac:link><ri:page …/></ac:link>` was dropped because goldmark cannot
  parse `<ac:link>` as raw inline HTML (the colon disqualifies it).
  Preserved via a `<span data-page-link …>` placeholder; the inverse is
  driven by a new `pageLinkDepth` counter in the renderer.
- Nested `<ol>` collapsed to a flat list and went loose because the
  fixed `2*(depth-1)` indent didn't satisfy CommonMark's column rule
  for ordered markers and a stray `\n` after each `<li>` made the list
  loose. Indent is now a per-level stack (3 for ol, 2 for ul/task) and
  list-item terminators use `ensureSingleNewline`.

Regression tests in converter/md2xml_test.go pin each fix; CLAUDE.md
documents the gotchas.
4 files changed, 396 insertions(+), 39 deletions(-)

M CLAUDE.md
M confluence/renderer.go
M converter/md2xml_test.go
M converter/xml2md.go
M CLAUDE.md => CLAUDE.md +15 -1
@@ 40,7 40,7 @@ confluence/

**CDATA preprocessing** — `xml2md.go` replaces `<![CDATA[...]]>` with `<cdatacontent>` fake elements before parsing, because `golang.org/x/net/html` doesn't handle CDATA. The reverse direction uses `confluence.escapeCDATA()` to split `]]>` sequences.

**Renderer state** — `confluence.Renderer` tracks `taskIDCounter`, `inTaskBody`, `inlineCommentDepth`, and pending attributes from HTML comments. This state is per-conversion and must remain consistent across the AST walk.
**Renderer state** — `confluence.Renderer` tracks `taskIDCounter`, `inTaskBody`, `inlineCommentDepth`, `pageLinkDepth`, and pending attributes from HTML comments. This state is per-conversion and must remain consistent across the AST walk.

**Template markers** — `<!-- MD_CONTENT_START -->` / `<!-- MD_CONTENT_END -->` delimit the editable region in Confluence pages. The `push --template` flow: fetch page → replace between markers → update with version increment.



@@ 52,3 52,17 @@ confluence/
- Task lists detected by checkbox presence, rendered as `<ac:task-list>`
- Code blocks → `<ac:structured-macro ac:name="code">` with CDATA body
- XML namespaces (`ac:`, `ri:`) are string-constructed, not from an XML library

### Round-trip gotchas (with regression tests)

These are subtle traps the round-trip has been bitten by; touching the related code without updating the tests in `converter/md2xml_test.go` will likely re-break a real document. Each item names the test that pins it.

- **GFM tables and `|` inside `<code>`** — a literal `|` in a cell is a column separator, even inside backticks. `xml2md.writeTableRow` runs every cell through `escapeTablePipes` which writes `\|`; the GFM parser strips the backslash at table-parse time *before* inline parsing, so the pipe survives inside the resulting code span. *Tests:* `TestRoundTrip_TablePipeInCode`, `TestEscapeTablePipes_NoDoubleEscape`.
- **Adjacent `<code>` merge must not span whitespace** — `<code>a</code><code>b</code>` (no gap) merges into one code span on the way to MD; `<code>a</code> <code>b</code>` (whitespace text node between) must stay as two. `isPrevSiblingCode` / `isNextSiblingCode` therefore look at the *immediate* neighbour only and refuse to skip whitespace text nodes. *Test:* `TestConfluenceToMarkdown_AdjacentCodeMerge` (case "whitespace-separated …").
- **Page links have no markdown form** — `<ac:link><ri:page ri:space-key="…" ri:content-title="…"/></ac:link>` is preserved as a `<span data-page-link="1" …>` placeholder. Goldmark cannot parse `<ac:link>` as raw inline HTML because of the `:` in the tag name, so a span wrapper is required. The reverse path lives in `confluence.Renderer.convertRawSpan`; closing `</span>` consults `pageLinkDepth` to emit `</ac:link>` at the right point. *Test:* `TestRoundTrip_PageLink`.
- **Nested-list indent depends on the *parent* list type** — CommonMark places content of `1. ` at column 4 and `- ` at column 3, so a nested item must be indented by ≥ the parent marker's width. `xmlConverter.listIndents` is a stack of per-level widths (3 for `<ol>`, 2 for `<ul>` / task-list); `itemIndent()` sums all *ancestor* contributions. Using a fixed `2*(depth-1)` collapses nested ordered lists into siblings. *Tests:* `TestRoundTrip_NestedOrderedList`, `TestRoundTrip_NestedMixedList`, `TestRoundTrip_DoubleNestedUnorderedList`.
- **Tight lists are one stray `\n` away from loose** — a blank line anywhere in a list turns it loose, which makes goldmark wrap every `<li>` in `<p>`. `ensureSingleNewline()` writes `\n` only when the buffer doesn't already end in one, preventing the double newline that appeared when a `<li>`'s body ended with a nested list (which itself terminated in `\n`). Always use it instead of `c.buf.WriteString("\n")` after walking list-item content.

### Verifying real documents

`mdcx verify <file.xml>` formats the input, round-trips it through `xml2md → md2xml`, formats the output, and diffs them. The justfile in `~/data/1` runs this against five real Confluence pages — exercise it after any change to converter or renderer code, since the unit tests can miss multi-feature interactions (e.g. a task list inside a `<div class="content-wrapper">` inside a table cell).

M confluence/renderer.go => confluence/renderer.go +50 -3
@@ 17,6 17,7 @@ type Renderer struct {
	taskIDCounter      int
	inTaskBody         bool
	inlineCommentDepth int
	pageLinkDepth      int
	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


@@ 535,9 536,17 @@ func (r *Renderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node,
func (r *Renderer) convertRawSpan(raw string) string {
	if !strings.HasPrefix(raw, "<span ") {
		// Handle closing tag — convert back if we're inside an inline comment
		if raw == "</span>" && r.inlineCommentDepth > 0 {
			r.inlineCommentDepth--
			return "</ac:inline-comment-marker>"
		// or page link. Page links nest inside inline comments rarely, so the
		// page-link counter takes precedence when both could apply.
		if raw == "</span>" {
			if r.pageLinkDepth > 0 {
				r.pageLinkDepth--
				return "</ac:link>"
			}
			if r.inlineCommentDepth > 0 {
				r.inlineCommentDepth--
				return "</ac:inline-comment-marker>"
			}
		}
		return raw
	}


@@ 571,6 580,44 @@ func (r *Renderer) convertRawSpan(raw string) string {
		}
	}

	// <span data-page-link="1" data-space-key="…" data-content-title="…">body</span>
	// or self-closing form when the original ac:link had no anchor body.
	if strings.Contains(raw, "data-page-link=") {
		space := extractAttrValue(raw, "data-space-key")
		title := extractAttrValue(raw, "data-content-title")
		anchor := extractAttrValue(raw, "data-anchor")
		var b strings.Builder
		b.WriteString(`<ac:link`)
		if anchor != "" {
			b.WriteString(` ac:anchor="`)
			b.WriteString(anchor)
			b.WriteString(`"`)
		}
		b.WriteString(`><ri:page`)
		if space != "" {
			b.WriteString(` ri:space-key="`)
			b.WriteString(space)
			b.WriteString(`"`)
		}
		if title != "" {
			b.WriteString(` ri:content-title="`)
			b.WriteString(title)
			b.WriteString(`"`)
		}
		b.WriteString(`/>`)
		// Self-closing span emits both opener and closer in convertRawSpan; for
		// `<span … />` the renderer never sees a separate closing tag, so wrap
		// up the ac:link here and signal the inline-span tracker not to expect one.
		if strings.HasSuffix(raw, "/>") {
			b.WriteString(`</ac:link>`)
			return b.String()
		}
		// For `<span …>body</span>`, defer the </ac:link> emission to the
		// matching </span>.
		r.pageLinkDepth++
		return b.String()
	}

	return raw
}


M converter/md2xml_test.go => converter/md2xml_test.go +159 -0
@@ 515,6 515,14 @@ func TestConfluenceToMarkdown_AdjacentCodeMerge(t *testing.T) {
			input:    `<p><code>a</code> text <code>b</code></p>`,
			expected: "`a` text `b`",
		},
		{
			// Regression: whitespace-only text between two <code> elements
			// previously caused the merge logic to swallow the gap and
			// concatenate the spans.
			name:     "whitespace-separated code elements stay separate",
			input:    `<p>Благодаря <code>previous_backup_id</code> <code>tt restore plan</code> видит разрыв.</p>`,
			expected: "`previous_backup_id` `tt restore plan`",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {


@@ 552,3 560,154 @@ func TestRoundTrip_InlineCommentFromRealXML(t *testing.T) {
	assert.Contains(t, xmlOutput, `ac:ref="b2f6ce98-4dc9-45e0-a9b6-b4a5109657ca"`)
	assert.Contains(t, xmlOutput, "Не майся дурью")
}

// TestRoundTrip_TablePipeInCode locks in the fix for table cells whose <code>
// contents include a literal `|`. Without escaping, the pipe was treated as a
// column separator at re-parse time, splitting a single cell across columns
// and dropping content. Cells must escape `|` as `\|` per the GFM spec.
func TestRoundTrip_TablePipeInCode(t *testing.T) {
	xmlInput := `<table><tbody>` +
		`<tr><th><p>Команда</p></th><th><p>Где</p></th><th><p>Действие</p></th></tr>` +
		`<tr><td><p><code>mode --target=&lt;incremental|full&gt; --storage=&lt;config&gt;</code></p></td>` +
		`<td><p>менеджер</p></td>` +
		`<td><p>Возвращает <code>mode</code> + <code>from_vclock</code>.</p></td></tr>` +
		`</tbody></table>`

	md, err := ConfluenceToMarkdown(xmlInput)
	require.NoError(t, err)
	// The pipe inside the code span must be escaped in the markdown so the
	// table parser does not treat it as a column boundary.
	assert.Contains(t, md, `\|`)

	xmlOutput, err := MarkdownToConfluence([]byte(md))
	require.NoError(t, err)
	// All three columns must survive the round-trip.
	assert.Contains(t, xmlOutput, `<code>mode --target=&lt;incremental|full&gt; --storage=&lt;config&gt;</code>`)
	assert.Contains(t, xmlOutput, "менеджер")
	assert.Contains(t, xmlOutput, "<code>from_vclock</code>")
}

// TestEscapeTablePipes_NoDoubleEscape guards the helper against doubling up
// already-escaped pipes (e.g. when a cell's rendered markdown was produced by
// an upstream that already escaped some pipes itself).
func TestEscapeTablePipes_NoDoubleEscape(t *testing.T) {
	cases := map[string]string{
		"":              "",
		"plain":         "plain",
		"a|b":           `a\|b`,
		`already\|done`: `already\|done`,
		"`a|b`":         "`a\\|b`",
	}
	for in, want := range cases {
		got := escapeTablePipes(in)
		assert.Equal(t, want, got, "input=%q", in)
	}
}

// TestRoundTrip_PageLink locks in the fix for <ac:link><ri:page …/></ac:link>.
// These page references have no plain markdown equivalent and previously got
// dropped on the way to MD, leaving an empty paragraph after a round-trip.
func TestRoundTrip_PageLink(t *testing.T) {
	xmlInput := `<p>Основной документ: <ac:link><ri:page ri:space-key="TSC" ri:content-title="Бекапы и Point-in-time recovery в кластере Tarantool" /></ac:link></p>`

	md, err := ConfluenceToMarkdown(xmlInput)
	require.NoError(t, err)
	require.Contains(t, md, `data-page-link`)
	require.Contains(t, md, `data-space-key="TSC"`)
	require.Contains(t, md, `data-content-title="Бекапы и Point-in-time recovery в кластере Tarantool"`)

	xmlOutput, err := MarkdownToConfluence([]byte(md))
	require.NoError(t, err)
	assert.Contains(t, xmlOutput, `<ac:link><ri:page ri:space-key="TSC" ri:content-title="Бекапы и Point-in-time recovery в кластере Tarantool"/></ac:link>`)
	assert.Contains(t, xmlOutput, "Основной документ:")
}

// TestRoundTrip_NestedOrderedList guards against the regression where a
// nested <ol> inside an outer <ol><li> was flattened into a single flat list
// because the markdown was emitted with a 2-space indent (insufficient for
// ordered lists, which need ≥3) and a blank line that turned the list loose.
func TestRoundTrip_NestedOrderedList(t *testing.T) {
	xmlInput := `<ol>` +
		`<li>Outer one.<ol>` +
		`<li>Inner one.</li>` +
		`<li>Inner two.</li>` +
		`</ol></li>` +
		`<li>Outer two.</li>` +
		`<li>Outer three.</li>` +
		`</ol>`

	md, err := ConfluenceToMarkdown(xmlInput)
	require.NoError(t, err)

	xmlOutput, err := MarkdownToConfluence([]byte(md))
	require.NoError(t, err)

	// Nested <ol> must survive — a flattened version would have 5 sibling <li>s.
	// Whitespace between closing tags is normalized away in this assertion.
	noWS := strings.Join(strings.Fields(xmlOutput), "")
	assert.Contains(t, noWS, "<li>Outerone.<ol>")
	assert.Contains(t, xmlOutput, "<li>Inner one.</li>")
	assert.Contains(t, xmlOutput, "<li>Inner two.</li>")
	assert.Contains(t, noWS, "</ol></li>")
	assert.Contains(t, xmlOutput, "<li>Outer two.</li>")

	// List must remain tight — no <p> wrappers around items.
	assert.NotContains(t, xmlOutput, "<li><p>Outer one.")
	assert.NotContains(t, xmlOutput, "<li><p>Inner one.")
}

// TestRoundTrip_NestedMixedList guards the same fix for an OL containing a
// nested UL, which is what the tt.xml mismatch exercised.
func TestRoundTrip_NestedMixedList(t *testing.T) {
	xmlInput := `<ol>` +
		`<li>Outer one.<ul>` +
		`<li>Bullet one.</li>` +
		`<li>Bullet two.</li>` +
		`</ul></li>` +
		`<li>Outer two.</li>` +
		`</ol>`

	md, err := ConfluenceToMarkdown(xmlInput)
	require.NoError(t, err)

	xmlOutput, err := MarkdownToConfluence([]byte(md))
	require.NoError(t, err)

	noWS := strings.Join(strings.Fields(xmlOutput), "")
	assert.Contains(t, noWS, "<li>Outerone.<ul>")
	assert.Contains(t, xmlOutput, "<li>Bullet one.</li>")
	assert.Contains(t, xmlOutput, "<li>Bullet two.</li>")
	assert.Contains(t, noWS, "</ul></li>")
	assert.Contains(t, xmlOutput, "<li>Outer two.</li>")
	assert.NotContains(t, xmlOutput, "<li><p>Outer one.")
}

// TestRoundTrip_DoubleNestedUnorderedList covers a UL nested inside another
// UL — pre-fix the cumulative indent computation didn't account for the
// parent list, so deeply nested items collapsed.
func TestRoundTrip_DoubleNestedUnorderedList(t *testing.T) {
	xmlInput := `<ul>` +
		`<li>Top one.<ul>` +
		`<li>Mid one.<ul>` +
		`<li>Deep one.</li>` +
		`<li>Deep two.</li>` +
		`</ul></li>` +
		`<li>Mid two.</li>` +
		`</ul></li>` +
		`</ul>`

	md, err := ConfluenceToMarkdown(xmlInput)
	require.NoError(t, err)

	xmlOutput, err := MarkdownToConfluence([]byte(md))
	require.NoError(t, err)

	noWS := strings.Join(strings.Fields(xmlOutput), "")
	assert.Contains(t, noWS, "<li>Topone.<ul>")
	assert.Contains(t, noWS, "<li>Midone.<ul>")
	assert.Contains(t, xmlOutput, "<li>Deep one.</li>")
	assert.Contains(t, xmlOutput, "<li>Deep two.</li>")
	assert.Contains(t, xmlOutput, "<li>Mid two.</li>")
	// Three nesting levels collapse to three closing </ul>s.
	assert.Equal(t, 3, strings.Count(xmlOutput, "</ul>"))
}

M converter/xml2md.go => converter/xml2md.go +172 -35
@@ 71,9 71,48 @@ func preprocessCDATA(s string) string {
}

type xmlConverter struct {
	buf        *bytes.Buffer
	listDepth  int
	inListItem bool
	buf         *bytes.Buffer
	listDepth   int
	inListItem  bool
	listIndents []int // per-level indent width contributed by each ancestor list
}

// pushList increments list depth and records the indent contribution for the
// new level (3 spaces for ordered, 2 for unordered/task). Items at deeper
// levels indent by the cumulative width of all ancestors.
func (c *xmlConverter) pushList(width int) {
	c.listDepth++
	c.listIndents = append(c.listIndents, width)
}

// popList undoes pushList.
func (c *xmlConverter) popList() {
	c.listDepth--
	if len(c.listIndents) > 0 {
		c.listIndents = c.listIndents[:len(c.listIndents)-1]
	}
}

// itemIndent returns the indent string for a list item at the current depth.
// It sums the contributions of all ancestor lists (everything except the
// innermost level, since that's the level whose marker we're about to emit).
func (c *xmlConverter) itemIndent() string {
	total := 0
	if len(c.listIndents) > 1 {
		for i := 0; i < len(c.listIndents)-1; i++ {
			total += c.listIndents[i]
		}
	}
	return strings.Repeat(" ", total)
}

// ensureSingleNewline writes a newline only if the buffer does not already end
// with one, preventing accidental blank lines that turn tight lists loose.
func (c *xmlConverter) ensureSingleNewline() {
	bs := c.buf.Bytes()
	if len(bs) == 0 || bs[len(bs)-1] != '\n' {
		c.buf.WriteByte('\n')
	}
}

func (c *xmlConverter) walkChildren(n *html.Node, depth int) {


@@ 183,22 222,22 @@ func (c *xmlConverter) walk(n *html.Node, depth int) {

	// Lists
	case tag == "ul":
		c.listDepth++
		c.pushList(2) // "- " marker → children indent 2 spaces
		if c.listDepth == 1 {
			c.buf.WriteString("\n")
		}
		c.walkChildren(n, depth)
		c.listDepth--
		c.popList()
		if c.listDepth == 0 {
			c.buf.WriteString("\n")
		}
	case tag == "ol":
		c.listDepth++
		c.pushList(3) // "1. " marker → children indent 3 spaces
		if c.listDepth == 1 {
			c.buf.WriteString("\n")
		}
		c.walkOL(n, depth)
		c.listDepth--
		c.popList()
		if c.listDepth == 0 {
			c.buf.WriteString("\n")
		}


@@ 209,13 248,12 @@ func (c *xmlConverter) walk(n *html.Node, depth int) {
		if hasTaskStatus(n) {
			// Task status handler will write the prefix, walkChildrenInline for text
			c.walkChildrenInline(n, depth)
			c.buf.WriteString("\n")
			c.ensureSingleNewline()
		} else {
			indent := strings.Repeat("  ", max(0, c.listDepth-1))
			c.buf.WriteString(indent)
			c.buf.WriteString(c.itemIndent())
			c.buf.WriteString("- ")
			c.walkChildrenInline(n, depth)
			c.buf.WriteString("\n")
			c.ensureSingleNewline()
		}
		c.inListItem = prev



@@ 314,8 352,8 @@ func (c *xmlConverter) handleConfluenceElement(n *html.Node, tag string, depth i

	// Confluence links (user mentions, page links)
	case strings.Contains(tag, "ac:link"):
		if c.hasUserChild(n) {
			c.walkChildren(n, depth)
		if pageNode := findPageChild(n); pageNode != nil {
			c.writePageLinkSpan(pageNode, n)
		} else {
			c.walkChildren(n, depth)
		}


@@ 341,15 379,15 @@ func (c *xmlConverter) handleConfluenceElement(n *html.Node, tag string, depth i

	// Confluence task lists
	case strings.Contains(tag, "task-list"):
		c.listDepth++
		c.pushList(2)
		c.walkChildren(n, depth)
		c.listDepth--
		c.popList()
	case strings.Contains(tag, "task-body"):
		c.walkChildren(n, depth)
		c.buf.WriteString("\n")
		c.ensureSingleNewline()
	case strings.Contains(tag, "task-status"):
		status := strings.TrimSpace(getTextContent(n))
		indent := strings.Repeat("  ", max(0, c.listDepth-1))
		indent := c.itemIndent()
		if status == "complete" {
			c.buf.WriteString(indent + "- [x] ")
		} else {


@@ 672,12 710,38 @@ func (c *xmlConverter) writeTableRow(cells []string, cols int) {
			cell = cells[i]
		}
		c.buf.WriteString(" ")
		c.buf.WriteString(cell)
		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 {


@@ 694,11 758,10 @@ func (c *xmlConverter) walkOL(n *html.Node, depth int) {
		}
		tag := strings.ToLower(child.Data)
		if tag == "li" {
			indent := strings.Repeat("  ", max(0, c.listDepth-1))
			c.buf.WriteString(indent)
			c.buf.WriteString(c.itemIndent())
			fmt.Fprintf(c.buf, "%d. ", idx)
			c.walkChildrenInline(child, depth)
			c.buf.WriteString("\n")
			c.ensureSingleNewline()
			idx++
		}
	}


@@ 1024,6 1087,80 @@ func (c *xmlConverter) hasUserChild(n *html.Node) bool {
	return false
}

// findPageChild returns the <ri:page> descendant of an <ac:link>, if any.
// ac:link wraps either an <ri:user>, <ri:page>, 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 <span data-page-link …> placeholder that survives
// markdown round-trip. md2xml's convertRawSpan reverses this back into a full
// <ac:link><ri:page …/></ac:link>.
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(`<span data-page-link="1"`)
	if space != "" {
		fmt.Fprintf(c.buf, ` data-space-key=%q`, space)
	}
	if title != "" {
		fmt.Fprintf(c.buf, ` data-content-title=%q`, title)
	}
	if anchor != "" {
		fmt.Fprintf(c.buf, ` data-anchor=%q`, anchor)
	}
	// Body of the ac:link, if any (plain-text-link-body or text node).
	body := pageLinkBody(link)
	if body != "" {
		c.buf.WriteString(">")
		c.buf.WriteString(body)
		c.buf.WriteString("</span>")
	} else {
		c.buf.WriteString("/>")
	}
}

// pageLinkBody returns the plain-text body of an <ac:link>, dropping the
// <ri:page> reference itself. Confluence allows <ac:plain-text-link-body> 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 {


@@ 1121,26 1258,26 @@ func extractAttrOrder(n *html.Node) string {
	return strings.Join(names, ",")
}

// isNextSiblingCode checks if the next non-whitespace sibling is a <code> element.
// isNextSiblingCode checks if the next sibling is a <code> element directly
// adjacent in the source. Any intervening text node (even whitespace) breaks
// adjacency, so `<code>a</code> <code>b</code>` does not merge.
func isNextSiblingCode(n *html.Node) bool {
	for s := n.NextSibling; s != nil; s = s.NextSibling {
		if s.Type == html.TextNode && strings.TrimSpace(s.Data) == "" {
			continue
		}
		return s.Type == html.ElementNode && strings.ToLower(s.Data) == "code"
	s := n.NextSibling
	if s == nil {
		return false
	}
	return false
	return s.Type == html.ElementNode && strings.ToLower(s.Data) == "code"
}

// isPrevSiblingCode checks if the previous non-whitespace sibling is a <code> element.
// isPrevSiblingCode checks if the previous sibling is a <code> element directly
// adjacent in the source. Any intervening text node (even whitespace) breaks
// adjacency.
func isPrevSiblingCode(n *html.Node) bool {
	for s := n.PrevSibling; s != nil; s = s.PrevSibling {
		if s.Type == html.TextNode && strings.TrimSpace(s.Data) == "" {
			continue
		}
		return s.Type == html.ElementNode && strings.ToLower(s.Data) == "code"
	s := n.PrevSibling
	if s == nil {
		return false
	}
	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.