Skip to content

Commit 85bbdc1

Browse files
committed
fix(confluence): distinguish inline formatting from block boundaries in extraction
Greptile: the recursive text-node walk unconditionally inserted a space between every text node, which fixed block-boundary fusion but broke genuinely inline-formatted text — "un<b>believe</b>able" became "un believe able" and "Hello<b>!</b>" became "Hello !", corrupting valid callout content on its way into the index. Adds an INLINE_FORMATTING_TAGS allowlist (b, strong, i, em, span, a, etc.): text flowing through those tags accumulates with no artificial separator, preserving exact source adjacency, while every other tag boundary (p, li, td, headings, br, ...) still flushes to a new segment — a block always implies a break even with no literal whitespace in the source, but an inline tag never does. Fixed one test that had encoded the old, incorrect expectation for two genuinely adjacent inline tags with no source whitespace between them, and added regression tests for mid-word inline formatting, punctuation attached to an inline tag, and a header with real source spacing.
1 parent 00355f7 commit 85bbdc1

2 files changed

Lines changed: 107 additions & 16 deletions

File tree

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

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,15 +110,25 @@ describe('preserveConfluenceCallouts', () => {
110110
expect(result).toContain('GitLab access requests go to the private channel instead.')
111111
})
112112

113-
it.concurrent('preserves word boundaries in a rich, multi-node Panel header', () => {
113+
it.concurrent('preserves word boundaries between a block header and its own content', () => {
114114
const html =
115-
'<div class="panel"><div class="panelHeader"><b>Warning:</b><span>Do not use</span></div>' +
115+
'<div class="panel"><div class="panelHeader"><b>Warning:</b></div>' +
116116
'<div class="panelContent"><p>See replacement form.</p></div></div>'
117117
const result = preserveConfluenceCallouts(html)
118-
expect(result).not.toContain('Warning:Do')
119-
expect(result).toContain('[CALLOUT: Warning: Do not use]')
118+
expect(result).toContain('[CALLOUT: Warning:] See replacement form.')
120119
})
121120

121+
it.concurrent(
122+
'keeps a rich header with a real source space intact, without adding a second one',
123+
() => {
124+
const html =
125+
'<div class="panel"><div class="panelHeader"><b>Warning:</b> <span>Do not use</span></div>' +
126+
'<div class="panelContent"><p>See replacement form.</p></div></div>'
127+
const result = preserveConfluenceCallouts(html)
128+
expect(result).toContain('[CALLOUT: Warning: Do not use]')
129+
}
130+
)
131+
122132
it.concurrent('falls back to a bare CALLOUT label when a Panel macro has no header text', () => {
123133
const html =
124134
'<div class="panel"><div class="panelContent"><p>Untitled panel body.</p></div></div>'
@@ -203,4 +213,36 @@ describe('preserveConfluenceCallouts', () => {
203213
expect(result).not.toContain('textafter')
204214
expect(result).toContain('Cell text quoted text after quote')
205215
})
216+
217+
it.concurrent(
218+
'does not inject an artificial space into inline-formatted text mid-word (inline vs. block regression)',
219+
() => {
220+
const html =
221+
'<div class="panel"><div class="panelContent">' +
222+
'<p>This is un<b>believe</b>able.</p>' +
223+
'</div></div>'
224+
const result = preserveConfluenceCallouts(html)
225+
expect(result).not.toContain('un believe able')
226+
expect(result).toContain('This is unbelieveable.')
227+
}
228+
)
229+
230+
it.concurrent('does not inject a space before punctuation carried by an inline tag', () => {
231+
const html =
232+
'<div class="confluence-information-macro confluence-information-macro-warning">' +
233+
'<div class="confluence-information-macro-body"><p>Do not proceed<b>!</b></p></div>' +
234+
'</div>'
235+
const result = preserveConfluenceCallouts(html)
236+
expect(result).not.toContain('proceed !')
237+
expect(result).toContain('[WARNING] Do not proceed!')
238+
})
239+
240+
it.concurrent('keeps natural word spacing when inline tags wrap a whole word', () => {
241+
const html =
242+
'<div class="panel"><div class="panelContent">' +
243+
'<p>Do <b>NOT</b> use this form.</p>' +
244+
'</div></div>'
245+
const result = preserveConfluenceCallouts(html)
246+
expect(result).toContain('Do NOT use this form.')
247+
})
206248
})

apps/sim/connectors/confluence/confluence.ts

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,34 +18,83 @@ const CALLOUT_LABELS: Record<string, string> = {
1818
error: '[ERROR]',
1919
}
2020

21+
/**
22+
* Inline formatting tags whose text flows directly into their surrounding
23+
* sentence with no implied word break — e.g. `un<b>believe</b>able` must stay
24+
* `unbelievable`, and `Hello<b>!</b>` must stay `Hello!`, not gain an
25+
* artificial space. Anything not in this set (p, li, td, div, headings, br,
26+
* etc.) is treated as a block boundary that always implies a break, even when
27+
* the source HTML has no literal whitespace there.
28+
*/
29+
const INLINE_FORMATTING_TAGS = new Set([
30+
'b',
31+
'strong',
32+
'i',
33+
'em',
34+
'u',
35+
's',
36+
'strike',
37+
'del',
38+
'ins',
39+
'sup',
40+
'sub',
41+
'small',
42+
'mark',
43+
'code',
44+
'span',
45+
'a',
46+
'abbr',
47+
'cite',
48+
'q',
49+
'kbd',
50+
'var',
51+
'samp',
52+
'time',
53+
])
54+
2155
/**
2256
* Cheerio's `.text()` concatenates every descendant text node with no
2357
* separator at all, so pulling a macro body's text in one call fuses adjacent
2458
* blocks together (e.g. a `<p>...for:</p>` immediately followed by
2559
* `<li>GitLab</li>` becomes `for:GitLab`, corrupting the very word boundaries
26-
* RAG chunking depends on). Selecting "block" elements and taking `.text()` on
27-
* each independently isn't enough either — nested blocks (a `<li>` containing
28-
* its own nested `<ul><li>`, a `<td>` containing a `<blockquote>`) still fuse
29-
* together the same way, one level deeper, since a matched outer block's
30-
* `.text()` recurses into and flattens its matched descendants too. Walking
31-
* every text node individually and joining them all with a single space
32-
* fixes both cases at once, with no double-counting, regardless of nesting
33-
* depth — matching how `html-parser.ts` already walks HTML for the same
34-
* reason elsewhere in this codebase.
60+
* RAG chunking depends on). Simply joining every text node with a space isn't
61+
* right either — that would corrupt genuinely inline-formatted text the same
62+
* way. This walks the DOM, accumulating text through inline tags without a
63+
* separator (preserving exact source adjacency) and flushing to a new segment
64+
* at every other tag boundary (a block always implies a break, regardless of
65+
* source whitespace) — matching how `html-parser.ts` already walks HTML for a
66+
* related reason elsewhere in this codebase, extended with the inline/block
67+
* distinction real Confluence rich text requires.
3568
*/
3669
function extractBlockJoinedText($: cheerio.CheerioAPI, $el: cheerio.Cheerio<any>): string {
3770
const parts: string[] = []
71+
let current = ''
72+
73+
const flush = () => {
74+
const text = current.trim()
75+
if (text) parts.push(text)
76+
current = ''
77+
}
78+
3879
const visit = ($node: cheerio.Cheerio<any>) => {
3980
$node.contents().each((_, child) => {
4081
if (child.type === 'text') {
41-
const text = $(child).text().trim()
42-
if (text) parts.push(text)
82+
current += $(child).text()
4383
} else if (child.type === 'tag') {
44-
visit($(child))
84+
const tag = child.tagName?.toLowerCase()
85+
if (tag && INLINE_FORMATTING_TAGS.has(tag)) {
86+
visit($(child))
87+
} else {
88+
flush()
89+
visit($(child))
90+
flush()
91+
}
4592
}
4693
})
4794
}
95+
4896
visit($el)
97+
flush()
4998
return parts.join(' ').trim()
5099
}
51100

0 commit comments

Comments
 (0)