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=<incremental|full> --storage=<config></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=<incremental|full> --storage=<config></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.