@@ -79,7 +79,9 @@ window.onload = function() {
7979 } ) ( ) )
8080
8181 const documenterTarget = document . querySelector ( '#documenter' ) ;
82- documenterTarget . parentNode . insertBefore ( header , documenterTarget ) ;
82+ if ( documenterTarget && documenterTarget . parentNode ) {
83+ documenterTarget . parentNode . insertBefore ( header , documenterTarget ) ;
84+ }
8385
8486 // === Site context banner for Docs ===
8587 // Add banner directly to navbar after header is created
@@ -104,48 +106,169 @@ window.onload = function() {
104106 }
105107 }
106108
107- // === Search results banner ===
108- // Add banner inside modal-card-head at the bottom when search results appear
109- function addSearchBanner ( ) {
110- const searchModal = document . getElementById ( 'search-modal' ) ;
111- if ( ! searchModal ) {
112- return ;
109+ }
110+
111+ document . addEventListener ( 'DOMContentLoaded' , function ( ) {
112+ // === Cross-site search across external docs ===
113+ ( function ( ) {
114+ const REMOTE_SOURCES = [
115+ { base : 'https://docs.rxinfer.com/stable' , label : 'RxInfer Docs' , index : null , promise : null } ,
116+ { base : 'https://reactivebayes.github.io/ReactiveMP.jl/stable' , label : 'ReactiveMP Docs' , index : null , promise : null } ,
117+ { base : 'https://reactivebayes.github.io/GraphPPL.jl/stable' , label : 'GraphPPL Docs' , index : null , promise : null } ,
118+ ] ;
119+
120+ function esc ( s ) {
121+ return ( s || '' ) . replace ( / [ & < > " ' ] / g, c =>
122+ ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } [ c ] ) ) ;
113123 }
114-
115- const modalCardHead = searchModal . querySelector ( '.modal-card-head' ) ;
116- if ( ! modalCardHead ) {
117- return ;
124+
125+ async function fetchRemoteIndex ( source ) {
126+ if ( source . index !== null ) return ;
127+ if ( source . promise ) return source . promise ;
128+
129+ source . promise = ( async ( ) => {
130+ try {
131+ const res = await fetch ( source . base . replace ( / \/ + $ / , '' ) + '/search_index.js' ) ;
132+ if ( ! res . ok ) return ;
133+ const text = await res . text ( ) ;
134+ const start = text . indexOf ( '{' ) ;
135+ const end = text . lastIndexOf ( '}' ) ;
136+ if ( start < 0 || end < start ) throw new Error ( 'Invalid format' ) ;
137+ source . index = JSON . parse ( text . slice ( start , end + 1 ) ) . docs || [ ] ;
138+ } catch ( e ) {
139+ source . index = [ ] ;
140+ }
141+ } ) ( ) ;
142+
143+ return source . promise ;
118144 }
119-
120- // Check if banner already exists
121- if ( modalCardHead . querySelector ( '#search-results-banner' ) ) {
122- return ;
145+
146+ async function fetchAllRemoteIndices ( ) {
147+ await Promise . all ( REMOTE_SOURCES . map ( fetchRemoteIndex ) ) ;
123148 }
124-
125- // Create a wrapper div for the banner
126- const bannerWrapper = document . createElement ( 'div' ) ;
127- bannerWrapper . style . cssText = 'width: 100%; position: absolute; bottom: 0; display: flex; justify-content: center; align-items: center;' ;
128- bannerWrapper . id = 'search-results-banner' ;
129- bannerWrapper . className = 'is-size-7' ;
130- bannerWrapper . innerHTML = `
131- <strong>Note:</strong> Search results do not include the <a href="https://docs.rxinfer.com/">documentation website</a>
132- ` ;
133-
134- // Insert after all existing children in modal-card-head
135- modalCardHead . appendChild ( bannerWrapper ) ;
136- }
137-
138- // Watch for search modal and results to appear (search results are dynamically loaded)
139- const observer = new MutationObserver ( function ( mutations ) {
140- addSearchBanner ( ) ;
141- } ) ;
142-
143- // Start observing the document body for changes
144- observer . observe ( document . body , {
145- childList : true ,
146- subtree : true
147- } ) ;
148-
149- // Also try immediately in case search modal already exists
150- setTimeout ( addSearchBanner , 100 ) ;
151- }
149+
150+ function searchRemote ( source , query ) {
151+ if ( ! source . index || ! source . index . length ) return [ ] ;
152+ const words = query . trim ( ) . toLowerCase ( ) . split ( / \s + / ) . filter ( w => w . length > 1 ) ;
153+ if ( ! words . length ) return [ ] ;
154+ return source . index . filter ( doc => {
155+ const hay = ( doc . title + ' ' + doc . text ) . toLowerCase ( ) ;
156+ return words . every ( w => hay . includes ( w ) ) ;
157+ } ) ;
158+ }
159+
160+ function extractSnippet ( text , query , contextLength = 80 ) {
161+ const words = query . trim ( ) . toLowerCase ( ) . split ( / \s + / ) . filter ( w => w . length > 1 ) ;
162+ if ( ! words . length || ! text ) return '' ;
163+
164+ const lowerText = text . toLowerCase ( ) ;
165+ let firstMatchIdx = - 1 ;
166+ for ( const word of words ) {
167+ const idx = lowerText . indexOf ( word ) ;
168+ if ( idx !== - 1 && ( firstMatchIdx === - 1 || idx < firstMatchIdx ) ) {
169+ firstMatchIdx = idx ;
170+ }
171+ }
172+
173+ if ( firstMatchIdx === - 1 ) return '' ;
174+
175+ const start = Math . max ( 0 , firstMatchIdx - contextLength ) ;
176+ const end = Math . min ( text . length , firstMatchIdx + contextLength ) ;
177+ let snippet = text . slice ( start , end ) ;
178+ if ( start > 0 ) snippet = '…' + snippet ;
179+ if ( end < text . length ) snippet = snippet + '…' ;
180+
181+ for ( const word of words ) {
182+ const regex = new RegExp ( `(${ word . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) } )` , 'gi' ) ;
183+ snippet = snippet . replace ( regex , '<mark style="background-color:var(--mark-bg,#fff3cd);padding:0 2px">$1</mark>' ) ;
184+ }
185+
186+ return snippet ;
187+ }
188+
189+ function renderSourceResults ( source , results , totalCount , query ) {
190+ const items = results . map ( doc => {
191+ const snippet = extractSnippet ( doc . text , query , 80 ) ;
192+ return `
193+ <a href="${ source . base . replace ( / \/ + $ / , '' ) } /${ doc . location || '' } " class="search-result-link w-100 is-flex is-flex-direction-column gap-2 px-4 py-2">
194+ <div class="w-100 is-flex is-flex-wrap-wrap is-justify-content-space-between is-align-items-flex-start">
195+ <div class="search-result-title has-text-weight-bold">${ esc ( doc . title ) } </div>
196+ <div class="property-search-result-badge">${ esc ( doc . category ) } </div>
197+ </div>
198+ ${ snippet ? `<div style="font-size:smaller;opacity:0.8;line-height:1.5">${ snippet } </div>` : '' }
199+ <div class="has-text-left" style="font-size:smaller;opacity:0.7">
200+ <i class="fas fa-external-link-alt"></i> ${ source . label } : ${ esc ( ( doc . location || '' ) . slice ( 0 , 60 ) ) }
201+ </div>
202+ </a>
203+ <div class="search-divider w-100"></div>` ;
204+ } ) . join ( '' ) ;
205+
206+ const countText = totalCount > results . length
207+ ? `${ results . length } of ${ totalCount } results`
208+ : `${ results . length } result${ results . length !== 1 ? 's' : '' } ` ;
209+
210+ return `
211+ <div style="padding:0.5rem 1rem;border-top:1px solid var(--card-border-color,#e9ecef);margin-top:0.5rem">
212+ <span class="is-size-7" style="opacity:0.7">Also from <strong>${ source . label } </strong> — ${ countText } </span>
213+ </div>
214+ ${ items } ` ;
215+ }
216+
217+ function renderResults ( sections , query ) {
218+ const html = sections . map ( section =>
219+ renderSourceResults ( section . source , section . results , section . totalCount , query )
220+ ) . join ( '' ) ;
221+ return `<div id="cross-site-results" class="w-100 is-flex is-flex-direction-column gap-2">${ html } </div>` ;
222+ }
223+
224+ let injecting = false ;
225+
226+ function inject ( ) {
227+ if ( injecting ) return ;
228+ const body = document . querySelector ( '.search-modal-card-body' ) ;
229+ const input = document . querySelector ( '.documenter-search-input' ) ;
230+ if ( ! body || ! input || body . querySelector ( '#cross-site-results' ) ) return ;
231+
232+ if ( REMOTE_SOURCES . some ( source => source . index === null ) ) {
233+ fetchAllRemoteIndices ( ) . then ( ( ) => setTimeout ( inject , 0 ) ) ;
234+ return ;
235+ }
236+
237+ const query = input . value || '' ;
238+ if ( query . trim ( ) . length < 2 ) return ;
239+
240+ const sections = REMOTE_SOURCES . map ( source => {
241+ const fullResults = searchRemote ( source , query ) ;
242+ return {
243+ source,
244+ totalCount : fullResults . length ,
245+ results : fullResults . slice ( 0 , 8 ) ,
246+ } ;
247+ } ) . filter ( section => section . totalCount > 0 ) ;
248+
249+ if ( ! sections . length ) return ;
250+
251+ injecting = true ;
252+ body . insertAdjacentHTML ( 'beforeend' , renderResults ( sections , query ) ) ;
253+ injecting = false ;
254+ }
255+
256+ let bodyObserver = null ;
257+ function connectBodyObserver ( ) {
258+ const body = document . querySelector ( '.search-modal-card-body' ) ;
259+ if ( ! body || bodyObserver ) return ;
260+ bodyObserver = new MutationObserver ( ( ) => { if ( ! injecting ) setTimeout ( inject , 30 ) ; } ) ;
261+ bodyObserver . observe ( body , { childList : true } ) ;
262+ }
263+
264+ new MutationObserver ( ( ) => {
265+ const modal = document . getElementById ( 'search-modal' ) ;
266+ if ( modal ) {
267+ if ( modal . classList . contains ( 'is-active' ) ) fetchAllRemoteIndices ( ) ;
268+ connectBodyObserver ( ) ;
269+ }
270+ } ) . observe ( document . body , { childList : true , subtree : true , attributes : true , attributeFilter : [ 'class' ] } ) ;
271+
272+ fetchAllRemoteIndices ( ) ;
273+ } ) ( ) ;
274+ } ) ;
0 commit comments