@@ -93,6 +93,7 @@ const INLINE_FORMATTING_TAGS = new Set([
9393 'var' ,
9494 'samp' ,
9595 'time' ,
96+ 'ac:inline-comment-marker' ,
9697] )
9798
9899/**
@@ -204,33 +205,98 @@ export function preserveConfluenceCallouts(html: string): string {
204205}
205206
206207const STORAGE_MACRO_SELECTOR = 'ac\\:structured-macro, ac\\:macro'
208+ const ADF_NODE_SELECTOR = 'ac\\:adf-node'
209+ /** Callout macros whose body is prefixed with a semantic label, as on the view path. */
210+ const LOCAL_CALLOUT_MACROS = new Set ( [ 'info' , 'note' , 'warning' , 'tip' , 'panel' ] )
211+ /**
212+ * Macros whose text is authored on the page itself: callouts, expand/excerpt/code
213+ * bodies, legacy `section`/`column` layouts (which wrap the entire body of pages
214+ * built in the old editor), Page Properties (`details`), table-wrapping macros,
215+ * and `status` lozenges. Everything else either resolves another resource
216+ * (include, jira, children, page tree, label reports) or is an app macro, and
217+ * may render differently for each reader.
218+ */
207219const LOCAL_STORAGE_MACROS = new Set ( [
208- 'info' ,
209- 'note' ,
210- 'warning' ,
211- 'tip' ,
212- 'panel' ,
220+ ...LOCAL_CALLOUT_MACROS ,
213221 'expand' ,
214222 'excerpt' ,
215223 'code' ,
216224 'noformat' ,
225+ 'section' ,
226+ 'column' ,
227+ 'details' ,
228+ 'toc-zone' ,
229+ 'chart' ,
230+ 'table-filter' ,
231+ 'table-chart' ,
232+ 'table-pivot' ,
233+ 'table-transformer' ,
234+ 'table-excerpt' ,
235+ 'table-plus' ,
236+ 'status' ,
217237] )
238+ /** New-editor nodes stored as ADF whose content is authored on the page. */
239+ const LOCAL_ADF_NODE_TYPES = new Set ( [ 'panel' , 'decision-list' , 'decision-item' ] )
240+ /** ADF nodes rendered by a Forge or Connect app; their output is resolved elsewhere. */
241+ const APP_ADF_NODE_TYPES = new Set ( [ 'extension' , 'bodiedExtension' , 'inlineExtension' ] )
242+ /** Storage-format bookkeeping that is never page prose. */
243+ const STORAGE_NOISE_SELECTOR = [
244+ 'ac\\:parameter' ,
245+ 'ac\\:default-parameter' ,
246+ 'ac\\:adf-attribute' ,
247+ 'ac\\:adf-fallback' ,
248+ 'ac\\:placeholder' ,
249+ 'ac\\:task-id' ,
250+ 'ac\\:task-uuid' ,
251+ 'ac\\:task-status' ,
252+ 'script' ,
253+ 'style' ,
254+ ] . join ( ', ' )
255+
256+ /** Recorded when a scoped page holds nothing but content resolved from elsewhere. */
257+ export const DYNAMIC_CONTENT_SKIP_REASON =
258+ 'Page only contains dynamic content (child lists, includes, or app macros) that Search cannot index'
259+
260+ export interface ConfluenceStorageText {
261+ text : string
262+ /** True when at least one non-local macro or app node was removed. */
263+ droppedDynamicContent : boolean
264+ }
218265
219266/**
220267 * Search authorizes the containing page, not content expanded from another
221268 * resource. Read authored storage text and known local macro bodies only;
222269 * inclusion and third-party macros may render differently for each reader.
223270 */
224- export function confluenceStorageToPlainText ( storage : string ) : string {
271+ export function extractConfluenceStorageText ( storage : string ) : ConfluenceStorageText {
225272 const $ = cheerio . load (
226273 storage ,
227274 { xml : { xmlMode : false , recognizeCDATA : true , recognizeSelfClosing : true } } ,
228275 false
229276 )
230- $ ( 'ac\\:adf-extension' ) . remove ( )
277+ let droppedDynamicContent = false
278+
279+ for ( const element of $ ( ADF_NODE_SELECTOR ) . toArray ( ) . reverse ( ) ) {
280+ const node = $ ( element )
281+ const type = node . attr ( 'type' ) ?? ''
282+ if ( ! LOCAL_ADF_NODE_TYPES . has ( type ) ) {
283+ if ( APP_ADF_NODE_TYPES . has ( type ) ) droppedDynamicContent = true
284+ node . remove ( )
285+ continue
286+ }
287+ const panelType = node . children ( 'ac\\:adf-attribute[key="panel-type"]' ) . text ( ) . trim ( )
288+ node . children ( 'ac\\:adf-attribute, ac\\:adf-fallback' ) . remove ( )
289+ const body = extractBlockJoinedText ( $ , node )
290+ const label =
291+ type === 'panel'
292+ ? ( CALLOUT_LABELS [ panelType === 'info' ? 'information' : panelType ] ?? '[CALLOUT]' )
293+ : ''
294+ node . replaceWith ( $ ( '<p></p>' ) . text ( [ label , body ] . filter ( Boolean ) . join ( ' ' ) ) )
295+ }
231296
232297 $ ( STORAGE_MACRO_SELECTOR ) . each ( ( _ , element ) => {
233298 if ( ! LOCAL_STORAGE_MACROS . has ( $ ( element ) . attr ( 'ac:name' ) ?? '' ) ) {
299+ droppedDynamicContent = true
234300 $ ( element ) . remove ( )
235301 }
236302 } )
@@ -248,13 +314,23 @@ export function confluenceStorageToPlainText(storage: string): string {
248314 ? title
249315 ? `[CALLOUT: ${ title } ]`
250316 : '[CALLOUT]'
251- : CALLOUT_LABELS [ name === 'info' ? 'information' : name ]
317+ : LOCAL_CALLOUT_MACROS . has ( name )
318+ ? CALLOUT_LABELS [ name === 'info' ? 'information' : name ]
319+ : ''
252320 const text = [ label , name === 'panel' ? '' : title , body ] . filter ( Boolean ) . join ( ' ' )
253321 macro . replaceWith ( $ ( '<p></p>' ) . text ( text ) )
254322 }
255323
256- $ ( 'ac\\:parameter, ac\\:default-parameter, script, style' ) . remove ( )
257- return extractBlockJoinedText ( $ , $ . root ( ) ) . replace ( / \s + / g, ' ' ) . trim ( )
324+ $ ( STORAGE_NOISE_SELECTOR ) . remove ( )
325+ return {
326+ text : extractBlockJoinedText ( $ , $ . root ( ) ) . replace ( / \s + / g, ' ' ) . trim ( ) ,
327+ droppedDynamicContent,
328+ }
329+ }
330+
331+ /** Plain text of a storage-format body; see {@link extractConfluenceStorageText}. */
332+ export function confluenceStorageToPlainText ( storage : string ) : string {
333+ return extractConfluenceStorageText ( storage ) . text
258334}
259335
260336function usesPermissionScopedContent ( syncContext ?: Record < string , unknown > ) : boolean {
@@ -313,7 +389,7 @@ export function readIncludedLabels(page: Record<string, unknown>): string[] {
313389 * ordinary knowledge bases retain their existing rendered representation.
314390 */
315391const CONTENT_REPRESENTATION = 'view-callouts'
316- const SCOPED_CONTENT_REPRESENTATION = 'storage-local-body-v1 '
392+ const SCOPED_CONTENT_REPRESENTATION = 'storage-local-body-v2 '
317393
318394/**
319395 * Produces a canonical metadata stub with a deterministic contentHash that
@@ -690,9 +766,8 @@ export const confluenceConnector: ConnectorConfig = {
690766 throw new Error ( `Confluence content is missing its ${ bodyFormat } body` )
691767 }
692768 const rawContent = representation . value
693- const plainText = scopedContent
694- ? confluenceStorageToPlainText ( rawContent )
695- : htmlToPlainText ( preserveConfluenceCallouts ( rawContent ) )
769+ const scoped = scopedContent ? extractConfluenceStorageText ( rawContent ) : null
770+ const plainText = scoped ? scoped . text : htmlToPlainText ( preserveConfluenceCallouts ( rawContent ) )
696771
697772 const links = page . _links as Record < string , unknown > | undefined
698773 const stub = pageToStub (
@@ -707,7 +782,12 @@ export const confluenceConnector: ConnectorConfig = {
707782
708783 if ( ! plainText . trim ( ) ) {
709784 return {
710- ...markSkipped ( stub , 'Document contains no extractable text' ) ,
785+ ...markSkipped (
786+ stub ,
787+ scoped ?. droppedDynamicContent
788+ ? DYNAMIC_CONTENT_SKIP_REASON
789+ : 'Document contains no extractable text'
790+ ) ,
711791 skippedExistingDisposition : 'replace' ,
712792 }
713793 }
0 commit comments