Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions internal/convert/aclink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,31 @@ func TestACLinkResolvedPage(t *testing.T) {
}
}

// TestACLinkCoalescesSplitBoldMark covers the same editor-induced mark split
// coalesceSplitMarks repairs for <a> (storage_to_md_test.go), but for an
// internal <ac:link>: bold text followed by a bold internal page link comes
// back from Confluence's editor as two adjacent runs sharing the mark instead
// of one nested element -- <strong>text </strong><ac:link>...<ac:link-body>
// <strong>y</strong></ac:link-body></ac:link> -- because the link's visible
// text lives inside ac:link-body, one level deeper than <a>'s. Verified live
// 2026-08-30 the same way as the <a> case: a direct atlas_doc_format PUT with
// the link mark's href pointing at another Confluence page.
func TestACLinkCoalescesSplitBoldMark(t *testing.T) {
storage := `<p><strong>some text </strong><ac:link><ri:page ri:content-title="IT 2026 Roadmap" />` +
`<ac:link-body><strong>y</strong></ac:link-body></ac:link></p>`
want := "**some text [y](" + pageURL + ")**"

got, err := convert.StorageToMarkdown(storage, convert.StorageOptions{
PageLinks: map[convert.PageLinkTarget]string{{Title: "IT 2026 Roadmap"}: pageURL},
})
if err != nil {
t.Fatalf("StorageToMarkdown: %v", err)
}
if strings.TrimSpace(got) != want {
t.Errorf("got %q, want %q", strings.TrimSpace(got), want)
}
}

// TestACLinkUnresolvedPageIsPassedThrough is the fallback that keeps a failed or
// skipped lookup from silently deleting a link. A markdown link with no
// destination would be worse than the storage, which still works.
Expand Down
153 changes: 152 additions & 1 deletion internal/convert/storage_to_md.go
Original file line number Diff line number Diff line change
Expand Up @@ -515,12 +515,163 @@ func (r *mdRenderer) renderCallout(n *snode, macro string) string {
// renderInlineChildren renders a node's children as a single inline string.
func (r *mdRenderer) renderInlineChildren(n *snode) string {
var b strings.Builder
for _, k := range n.kids {
for _, k := range coalesceSplitMarks(n.kids) {
b.WriteString(r.renderInline(k))
}
return strings.TrimSpace(b.String())
}

// formatMarks are the inline formatting tags coalesceSplitMarks may hoist
// across a link boundary.
var formatMarks = map[string]bool{
"strong": true, "b": true,
"em": true, "i": true,
"del": true, "s": true, "strike": true,
}

// coalesceSplitMarks merges a formatting run that Confluence's own editor can
// split around a link. ADF (Confluence's native document model) carries marks
// per text run rather than as nested elements, so "**text [link](url)**" --
// which markfluence always writes nested, as one <strong> wrapping both the
// text and the link -- comes back from a page that has since been edited and
// saved in Confluence's editor as two adjacent runs sharing the mark instead:
// <strong>text </strong><a href="url"><strong>link</strong></a>. Verified
// 2026-08-30 via a direct atlas_doc_format PUT of the unmodified ADF markfluence
// itself had published, which is what the editor does on any save; the same PUT
// with the link's href pointing at another Confluence page produces the
// identical split with <ac:link> in place of <a> (see isLinkNode). Rendered as
// two independent nodes that becomes "**text **[**link**](url)": the closing
// ** is preceded by a space, so CommonMark's flanking rule refuses to treat it
// as emphasis at all -- the markdown comes back not merely unstyled but
// literally reading "**text **". This restores the nested form before
// rendering, the only shape markdown can actually express, by hoisting the
// mark to wrap the whole run including the link and dropping the now-redundant
// inner one.
//
// Merging two adjacent same-tag mark elements outright (mergeMarkRun's first
// case, needed nowhere else) is what lets a third run on either side of the
// link fold into an already-repaired node; it is not itself a repair, since
// "<strong>a</strong><strong>b</strong>" is valid nested markdown either way.
func coalesceSplitMarks(kids []*snode) []*snode {
out := make([]*snode, 0, len(kids))
for _, k := range kids {
if len(out) > 0 {
if merged := mergeMarkRun(out[len(out)-1], k); merged != nil {
out[len(out)-1] = merged
continue
}
}
out = append(out, k)
}
return out
}

// isLinkNode reports whether n is a link element coalesceSplitMarks may hoist
// a mark across: a markdown link, or the editor's own internal <ac:link> (used
// for a page, space, or user link -- see aclink.go).
func isLinkNode(n *snode) bool {
return n.name == "a" || n.name == "ac:link"
}

// linkTextBody returns the node whose children hold a link's visible text --
// the <a> itself, or an <ac:link>'s <ac:link-body> -- or nil if it has neither.
// An <ac:link>'s other body spelling, ac:plain-text-link-body, holds CDATA and
// so can never carry a mark element to unwrap.
func linkTextBody(n *snode) *snode {
if n.name == "a" {
return n
}
return findChild(n, "ac:link-body")
}

// withLinkTextBody returns a copy of link node n with body's children replaced
// by kids -- unwrapping a mark mergeMarkRun is hoisting out of it. body is
// n itself for an <a>, or its <ac:link-body> child for an <ac:link>, whose
// other children (ri:page, ac:anchor, ...) must survive untouched.
func withLinkTextBody(n, body *snode, kids []*snode) *snode {
if n.name == "a" {
return &snode{name: "a", attrs: n.attrs, kids: kids}
}
newKids := make([]*snode, len(n.kids))
for i, k := range n.kids {
if k == body {
k = &snode{name: k.name, attrs: k.attrs, kids: kids}
}
newKids[i] = k
}
return &snode{name: n.name, attrs: n.attrs, kids: newKids}
}

// mergeMarkRun merges two adjacent inline nodes when they carry the same
// formatting mark: either both are the same mark element, or one is a mark and
// the other is a link whose entire visible text is that same mark (the split
// coalesceSplitMarks exists to repair). Returns nil when they don't combine.
func mergeMarkRun(prev, cur *snode) *snode {
switch {
case prev.name == cur.name && formatMarks[prev.name]:
return &snode{name: prev.name, attrs: mergeAttrs(prev.attrs, cur.attrs), kids: concatKids(prev.kids, cur.kids)}
case formatMarks[prev.name] && isLinkNode(cur):
if link := hoistMarkIntoLink(cur, prev.name); link != nil {
return &snode{name: prev.name, attrs: prev.attrs, kids: concatKids(prev.kids, []*snode{link})}
}
case isLinkNode(prev) && formatMarks[cur.name]:
if link := hoistMarkIntoLink(prev, cur.name); link != nil {
return &snode{name: cur.name, attrs: cur.attrs, kids: concatKids([]*snode{link}, cur.kids)}
}
}
return nil
}

// mergeAttrs unions two attribute maps; a key present in both keeps a's value,
// so merging n adjacent same-tag runs left to right is order-independent.
// nil-safe in both directions, since most snodes carry no attrs at all.
func mergeAttrs(a, b map[string]string) map[string]string {
if len(b) == 0 {
return a
}
out := make(map[string]string, len(a)+len(b))
for k, v := range b {
out[k] = v
}
for k, v := range a {
out[k] = v
}
return out
}

// hoistMarkIntoLink strips a redundant mark wrapping the entirety of link's
// visible text, returning the link with that text unwrapped, or nil if the
// link has no text body or is not entirely marked (a link only partly marked
// is left alone: there's nothing correct to hoist).
func hoistMarkIntoLink(link *snode, mark string) *snode {
body := linkTextBody(link)
if body == nil {
return nil
}
inner, ok := unwrapSoleMark(body, mark)
if !ok {
return nil
}
return withLinkTextBody(link, body, inner)
}

// unwrapSoleMark reports whether n's entire content is a single child element
// carrying the given mark, returning that child's own children -- the link's
// content with the redundant inner mark stripped.
func unwrapSoleMark(n *snode, mark string) ([]*snode, bool) {
if len(n.kids) != 1 || n.kids[0].name != mark {
return nil, false
}
return n.kids[0].kids, true
}

// concatKids returns a with b appended, without aliasing either's backing array.
func concatKids(a, b []*snode) []*snode {
out := make([]*snode, 0, len(a)+len(b))
out = append(out, a...)
return append(out, b...)
}

// renderInline renders one inline node.
func (r *mdRenderer) renderInline(n *snode) string {
if n.name == "" {
Expand Down
48 changes: 48 additions & 0 deletions internal/convert/storage_to_md_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,54 @@ func TestStorageToMarkdownStripsGeneratedIDs(t *testing.T) {
}
}

// TestStorageToMarkdownCoalescesSplitMarks checks the repair for a bold (or
// italic) span that Confluence's own editor splits around a link: saving a page
// through the editor stores marks per text run rather than as nested elements,
// so "<strong>text <a>link</a></strong>" -- which is all MdToConfluence ever
// writes -- can come back as two adjacent runs sharing the mark instead,
// "<strong>text </strong><a><strong>link</strong></a>". Rendered independently
// that produces "**text **[**link**](url)", whose closing ** is preceded by a
// space and so does not open emphasis at all under CommonMark's flanking rule --
// verified live on 2026-08-30 by PUTting a page's own unmodified
// atlas_doc_format back at it, which is what the editor does on every save.
func TestStorageToMarkdownCoalescesSplitMarks(t *testing.T) {
tests := map[string]struct {
in, want string
}{
"bold text then bold link": {
in: `<p><strong>some text </strong><a href="https://example.com"><strong>x</strong></a></p>`,
want: "**some text [x](https://example.com)**\n",
},
"bold link then bold text": {
in: `<p><a href="https://example.com"><strong>x</strong></a><strong> more text</strong></p>`,
want: "**[x](https://example.com) more text**\n",
},
"italic text then italic link": {
in: `<p><em>x </em><a href="https://example.com"><em>y</em></a></p>`,
want: "*x [y](https://example.com)*\n",
},
"adjacent same-mark runs with no link still merge": {
in: `<p><strong>a</strong><strong>b</strong></p>`,
want: "**ab**\n",
},
"link only partly bold does not merge": {
in: `<p><strong>a </strong><a href="https://example.com">b<strong>c</strong></a></p>`,
want: "**a**[b**c**](https://example.com)\n",
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got, err := convert.StorageToMarkdown(tc.in, convert.StorageOptions{})
if err != nil {
t.Fatalf("StorageToMarkdown: %v", err)
}
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}

// TestRoundTripPassthrough verifies that the raw-storage passthrough cases
// (column layouts and unknown macros) survive markdown -> storage -> markdown
// unchanged -- the whole point of emitting them in a form MdToConfluence
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<p>Editor-split bold text and link: <strong>some text </strong><a href="https://example.com"><strong>x</strong></a>.</p>
<p>Editor-split italic link then text: <a href="https://example.com"><em>x</em></a><em> more text</em>.</p>
<p>Adjacent same-tag runs with no link nearby: <strong>a</strong><strong>b</strong>.</p>
<p>A link only partly bold does not merge: <strong>a </strong><a href="https://example.com">b<strong>c</strong></a>.</p>
7 changes: 7 additions & 0 deletions internal/convert/testdata/storage2md/split-marks/output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Editor-split bold text and link: **some text [x](https://example.com)**.

Editor-split italic link then text: *[x](https://example.com) more text*.

Adjacent same-tag runs with no link nearby: **ab**.

A link only partly bold does not merge: **a**[b**c**](https://example.com).