Skip to content

Commit a395b5f

Browse files
committed
fix(confluence): process nested panels/macros innermost-first
Cursor: processing matches in document order (outermost first) read a nested, not-yet-converted panel/macro as plain body text before it ever got its own bracketed label, silently dropping the inner callout's type. Worse, an untitled outer panel's `.find('.panelHeader')` could reach past its own missing header into a nested panel's header and adopt it as its own title. Replaces the two independent .each() passes with a loop that converts only "leaf" macros (no remaining nested macro/panel inside them) and repeats until none are left. This processes innermost-first, so a nested macro is already its own bracketed <p> by the time its parent's body/header text is read, and an untitled outer panel's .find() can no longer reach a header that isn't its own, since a leaf by definition has no nested panel left to reach into.
1 parent 85bbdc1 commit a395b5f

2 files changed

Lines changed: 82 additions & 19 deletions

File tree

apps/sim/connectors/confluence/confluence.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,4 +245,46 @@ describe('preserveConfluenceCallouts', () => {
245245
const result = preserveConfluenceCallouts(html)
246246
expect(result).toContain('Do NOT use this form.')
247247
})
248+
249+
it.concurrent(
250+
'labels a nested panel-in-panel with both its own and its parent label (nesting regression)',
251+
() => {
252+
const html =
253+
'<div class="panel"><div class="panelHeader"><b>Outer</b></div><div class="panelContent">' +
254+
'<div class="panel"><div class="panelHeader"><b>Inner</b></div>' +
255+
'<div class="panelContent"><p>inner body</p></div></div>' +
256+
'</div></div>'
257+
const result = preserveConfluenceCallouts(html)
258+
expect(result).toContain('[CALLOUT: Outer]')
259+
expect(result).toContain('[CALLOUT: Inner] inner body')
260+
}
261+
)
262+
263+
it.concurrent(
264+
'labels a nested info-macro inside a panel with its own type instead of dropping it',
265+
() => {
266+
const html =
267+
'<div class="panel"><div class="panelContent">' +
268+
'<div class="confluence-information-macro confluence-information-macro-warning">' +
269+
'<div class="confluence-information-macro-body"><p>Do not use this.</p></div></div>' +
270+
'</div></div>'
271+
const result = preserveConfluenceCallouts(html)
272+
expect(result).toContain('[WARNING] Do not use this.')
273+
}
274+
)
275+
276+
it.concurrent(
277+
"does not let an untitled outer panel adopt a nested panel's header as its own",
278+
() => {
279+
const html =
280+
'<div class="panel"><div class="panelContent">' +
281+
'<div class="panel"><div class="panelHeader"><b>Inner title</b></div>' +
282+
'<div class="panelContent"><p>inner body</p></div></div>' +
283+
'</div></div>'
284+
const result = preserveConfluenceCallouts(html)
285+
// The outer panel has no header of its own — it must fall back to a
286+
// bare [CALLOUT], not steal "Inner title" from the nested panel.
287+
expect(result).toContain('[CALLOUT] [CALLOUT: Inner title] inner body')
288+
}
289+
)
248290
})

apps/sim/connectors/confluence/confluence.ts

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ function extractBlockJoinedText($: cheerio.CheerioAPI, $el: cheerio.Cheerio<any>
9898
return parts.join(' ').trim()
9999
}
100100

101+
/** Matches either flavor of panel/macro this function rewrites. */
102+
const MACRO_SELECTOR = 'div.confluence-information-macro, div.panel'
103+
101104
/**
102105
* Confluence's rendered `view` HTML wraps Info/Note/Warning/Tip macros in
103106
* `confluence-information-macro confluence-information-macro-{type}` divs, and
@@ -108,31 +111,49 @@ function extractBlockJoinedText($: cheerio.CheerioAPI, $el: cheerio.Cheerio<any>
108111
* would erase any newline-based separation too. Each detected panel is rewritten
109112
* into a single bracketed label plus its own text so the callout semantic
110113
* survives both the tag strip and the whitespace collapse.
114+
*
115+
* A panel can itself contain another panel or macro (e.g. a nested Note inside
116+
* a Warning panel). Processing matches in document order — outermost first —
117+
* would read a not-yet-converted nested macro as plain body text before it
118+
* ever got its own label, silently dropping the inner callout's semantic, and
119+
* `.find('.panelHeader')` would then risk pulling a nested panel's header up
120+
* as if it were the outer panel's own title. Converting only "leaf" macros
121+
* (ones with no remaining nested macro/panel inside them) and repeating until
122+
* none are left processes innermost-first, so a nested macro is already a
123+
* bracketed `<p>` by the time its parent's body/header text is read — at which
124+
* point it correctly reads as plain text carrying its own label.
111125
*/
112126
export function preserveConfluenceCallouts(html: string): string {
113127
if (!html) return html
114128

115129
const $ = cheerio.load(html)
116130

117-
$('div.confluence-information-macro').each((_, el) => {
118-
const $el = $(el)
119-
const type = ($el.attr('class') ?? '')
120-
.match(/confluence-information-macro-(\w+)/)?.[1]
121-
?.toLowerCase()
122-
const label = (type && CALLOUT_LABELS[type]) || CALLOUT_LABELS.information
123-
const macroBody = $el.find('.confluence-information-macro-body').first()
124-
const body = extractBlockJoinedText($, macroBody.length > 0 ? macroBody : $el)
125-
$el.replaceWith($('<p></p>').text(`${label} ${body}`))
126-
})
127-
128-
$('div.panel').each((_, el) => {
129-
const $el = $(el)
130-
const headerText = extractBlockJoinedText($, $el.find('.panelHeader').first())
131-
const panelContent = $el.find('.panelContent').first()
132-
const bodyText = extractBlockJoinedText($, panelContent.length > 0 ? panelContent : $el)
133-
const label = headerText ? `[CALLOUT: ${headerText}]` : '[CALLOUT]'
134-
$el.replaceWith($('<p></p>').text(`${label} ${bodyText}`))
135-
})
131+
let progressed = true
132+
while (progressed) {
133+
progressed = false
134+
const leaves = $(MACRO_SELECTOR).filter((_, el) => $(el).find(MACRO_SELECTOR).length === 0)
135+
if (leaves.length === 0) break
136+
137+
leaves.each((_, el) => {
138+
const $el = $(el)
139+
if ($el.hasClass('confluence-information-macro')) {
140+
const type = ($el.attr('class') ?? '')
141+
.match(/confluence-information-macro-(\w+)/)?.[1]
142+
?.toLowerCase()
143+
const label = (type && CALLOUT_LABELS[type]) || CALLOUT_LABELS.information
144+
const macroBody = $el.find('.confluence-information-macro-body').first()
145+
const body = extractBlockJoinedText($, macroBody.length > 0 ? macroBody : $el)
146+
$el.replaceWith($('<p></p>').text(`${label} ${body}`))
147+
} else {
148+
const headerText = extractBlockJoinedText($, $el.find('.panelHeader').first())
149+
const panelContent = $el.find('.panelContent').first()
150+
const bodyText = extractBlockJoinedText($, panelContent.length > 0 ? panelContent : $el)
151+
const label = headerText ? `[CALLOUT: ${headerText}]` : '[CALLOUT]'
152+
$el.replaceWith($('<p></p>').text(`${label} ${bodyText}`))
153+
}
154+
progressed = true
155+
})
156+
}
136157

137158
return $.html()
138159
}

0 commit comments

Comments
 (0)