Skip to content

Commit eb307a8

Browse files
authored
Merge branch 'main' into main
2 parents fede0c4 + 3b991ef commit eb307a8

1 file changed

Lines changed: 166 additions & 43 deletions

File tree

docs/src/assets/header.js

Lines changed: 166 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>&nbsp;Search results do not include the&nbsp;<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

Comments
 (0)