SearchBar.js
1 // SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 2 // 3 // SPDX-License-Identifier: Apache-2.0 4 5 import React, { 6 useCallback, 7 useEffect, 8 useMemo, 9 useRef, 10 useState, 11 } from "react"; 12 import { useHistory } from "@docusaurus/router"; 13 import { useColorMode } from "@docusaurus/theme-common"; 14 import debounce from "lodash/debounce"; 15 import styles from "./styles.module.css"; 16 17 const MIN_QUERY_LENGTH = 3; 18 const DEBOUNCE_DELAY = 650; 19 20 const API_REFERENCE_KEY = "api-reference"; 21 const DOCUMENTATION_KEY = "documentation"; 22 23 const titleCase = (s) => { 24 return s 25 .toLowerCase() 26 .split(/[\s_-]+/) 27 .filter(Boolean) 28 .map((w) => w[0]?.toUpperCase() + w.slice(1)) 29 .join(" "); 30 }; 31 32 const toPlainText = (s) => { 33 if (!s) return ""; 34 // Strip HTML tags 35 let t = s.replace(/<[^>]+>/g, " "); 36 // Convert markdown links [text](url) -> text 37 t = t.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1"); 38 // Replace markdown special characters with spaces 39 t = t.replace(/[#>*_`~\-]+/g, " "); 40 // Collapse whitespace 41 t = t.replace(/\s+/g, " ").trim(); 42 return t; 43 }; 44 45 const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 46 47 const buildSnippet = (content, query, maxLen = 200) => { 48 const text = toPlainText(content); 49 if (!text) return ""; 50 51 const terms = query.trim().split(/\s+/).filter(Boolean); 52 if (terms.length === 0) { 53 return text.length > maxLen ? text.slice(0, maxLen) + "…" : text; 54 } 55 56 const regex = new RegExp(`(${terms.map(escapeRegExp).join("|")})`, "ig"); 57 const match = regex.exec(text); 58 const start = match ? Math.max(0, match.index - 60) : 0; 59 const end = Math.min(text.length, start + maxLen); 60 let slice = text.slice(start, end); 61 // Only <mark> is injected; original HTML was stripped above 62 let highlighted = slice.replace(regex, "<mark>$1</mark>"); 63 64 if (start > 0) highlighted = "… " + highlighted; 65 66 if (end < text.length) highlighted += " …"; 67 68 return highlighted; 69 }; 70 71 const extractTitle = (content, fileName, path) => { 72 const h1 = 73 content.match(/^#\s+(.+?)\s*$/m)?.[1] || 74 content.match(/^##\s+(.+?)\s*$/m)?.[1]; 75 76 if (h1) return toPlainText(h1); 77 78 if (fileName) return titleCase(fileName.replace(/\.html?$/i, "")); 79 80 if (path) { 81 const last = path.split("/").filter(Boolean).pop() || path; 82 return titleCase(last.replace(/\.html?$/i, "")); 83 } 84 85 return "Untitled"; 86 }; 87 88 const toDocUrl = (url) => { 89 if (!url) return "/docs"; 90 91 let p = url; 92 p = p.replace(/\/index\.html?$/i, "/"); 93 p = p.replace(/\.html?$/i, ""); 94 95 // Handle reference URLs 96 if (p.includes("/reference")) { 97 p = p.split("/reference").pop(); 98 if (!p.startsWith("/")) p = "/" + p; 99 return "/reference" + p; 100 } 101 102 // Handle docs URLs 103 p = p.split("/docs").pop(); 104 if (!p.startsWith("/")) p = "/" + p; 105 return "/docs" + p; 106 }; 107 108 const groupByPage = (documents) => { 109 const groups = new Map(); 110 111 for (const doc of documents) { 112 const key = 113 doc.meta?.original_file_path || 114 doc.meta?.url || 115 doc.file?.name || 116 "unknown"; 117 118 if (!groups.has(key)) groups.set(key, []); 119 120 groups.get(key).push(doc); 121 } 122 123 return groups; 124 }; 125 126 const categorizeDocument = (doc, path) => { 127 // First, try to use the navigation metadata from the document 128 const navigation = doc?.meta?.type; 129 if (navigation) { 130 // Normalize the navigation value 131 const normalized = navigation.toLowerCase().trim(); 132 if (normalized === API_REFERENCE_KEY) { 133 return API_REFERENCE_KEY; 134 } 135 if (normalized === DOCUMENTATION_KEY) { 136 return DOCUMENTATION_KEY; 137 } 138 } 139 140 // Fall back to path-based categorization for documents without metadata 141 if (!path) return DOCUMENTATION_KEY; 142 143 const lowerPath = path.toLowerCase(); 144 145 if (lowerPath.includes("/reference/")) { 146 return API_REFERENCE_KEY; 147 } 148 149 // Default for most docs 150 return DOCUMENTATION_KEY; 151 }; 152 153 const toResults = (documents, query) => { 154 const groups = groupByPage(documents); 155 return Array.from(groups.entries()) 156 .map(([path, docs]) => { 157 const best = 158 docs.reduce( 159 (a, b) => ((b.score ?? 0) > (a.score ?? 0) ? b : a), 160 docs[0] 161 ) || docs[0]; 162 163 return { 164 title: extractTitle(best?.content || "", best?.file?.name, path), 165 url: toDocUrl(best?.meta?.url), 166 snippet: buildSnippet(best?.content || "", query), 167 path, 168 score: best?.score, 169 category: categorizeDocument(best, path), 170 type: best?.meta?.type, 171 }; 172 }) 173 .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); 174 }; 175 176 export default function SearchBar() { 177 const [query, setQuery] = useState(""); 178 const [showModal, setShowModal] = useState(false); 179 const [activeFilter, setActiveFilter] = useState("all"); 180 const [isSearching, setIsSearching] = useState(false); 181 const [error, setError] = useState(null); 182 const [results, setResults] = useState([]); 183 184 const searchInputRef = useRef(null); 185 const modalRef = useRef(null); 186 const requestAbortRef = useRef(null); 187 188 const history = useHistory(); 189 const { colorMode } = useColorMode(); 190 const isDarkMode = colorMode === "dark"; 191 192 const performSearch = useCallback( 193 async (searchQuery) => { 194 if (requestAbortRef.current) { 195 requestAbortRef.current.abort(); 196 } 197 const controller = new AbortController(); 198 requestAbortRef.current = controller; 199 200 setError(null); 201 202 try { 203 const response = await fetch(`/api/search`, { 204 method: "POST", 205 headers: { 206 "Content-Type": "application/json", 207 }, 208 body: JSON.stringify({ query: searchQuery, filter: activeFilter }), 209 signal: controller.signal, 210 }); 211 212 if (!response.ok) { 213 throw new Error(`API error: ${response.statusText}`); 214 } 215 216 const data = await response.json(); 217 if (controller.signal.aborted) return; 218 219 const documents = data?.results?.[0]?.documents || []; 220 setResults(toResults(documents, searchQuery)); 221 } catch (err) { 222 if (err?.name === "AbortError") return; 223 setError("Failed to fetch search results."); 224 setResults([]); 225 } finally { 226 if (!requestAbortRef.current?.signal.aborted) { 227 setIsSearching(false); 228 } 229 } 230 }, 231 [activeFilter] 232 ); 233 234 const debouncedSearch = useMemo( 235 () => debounce(performSearch, DEBOUNCE_DELAY), 236 [performSearch] 237 ); 238 239 useEffect(() => { 240 return () => { 241 debouncedSearch.cancel(); 242 if (requestAbortRef.current) { 243 requestAbortRef.current.abort(); 244 } 245 }; 246 }, [debouncedSearch]); 247 248 // Filter results based on active filter 249 const filteredResults = useMemo(() => { 250 if (activeFilter === "all") return results; 251 return results.filter((result) => result.category === activeFilter); 252 }, [results, activeFilter]); 253 254 const handleInputChange = useCallback( 255 (e) => { 256 setIsSearching(true); 257 258 const value = e.target.value; 259 setQuery(value); 260 261 if (value.length < MIN_QUERY_LENGTH) { 262 debouncedSearch.cancel(); 263 if (requestAbortRef.current) requestAbortRef.current.abort(); 264 setResults([]); 265 setIsSearching(false); 266 setError(null); 267 return; 268 } 269 270 debouncedSearch(value); 271 }, 272 [debouncedSearch] 273 ); 274 275 const handleResultClick = useCallback( 276 (url) => { 277 history.push(url); 278 setShowModal(false); 279 setQuery(""); 280 setResults([]); 281 }, 282 [history] 283 ); 284 285 // Close modal when clicking outside or pressing Escape 286 useEffect(() => { 287 function handleClickOutside(event) { 288 if ( 289 modalRef.current && 290 event.target instanceof Node && 291 !modalRef.current.contains(event.target) 292 ) { 293 setShowModal(false); 294 } 295 } 296 297 function handleEscape(event) { 298 if (event.key === "Escape") { 299 setShowModal(false); 300 } 301 } 302 303 if (showModal) { 304 document.addEventListener("mousedown", handleClickOutside); 305 document.addEventListener("keydown", handleEscape); 306 document.body.style.overflow = "hidden"; // Prevent background scrolling 307 } 308 309 return () => { 310 document.removeEventListener("mousedown", handleClickOutside); 311 document.removeEventListener("keydown", handleEscape); 312 document.body.style.overflow = "unset"; 313 }; 314 }, [showModal]); 315 316 const handleKeyDown = useCallback( 317 (e) => { 318 if (e.key === "Enter" && query.trim().length >= MIN_QUERY_LENGTH) { 319 debouncedSearch.cancel(); 320 performSearch(query.trim()); 321 } 322 }, 323 324 [debouncedSearch, performSearch, query] 325 ); 326 327 const filters = [ 328 { id: "all", label: "All" }, 329 { id: DOCUMENTATION_KEY, label: "Documentation" }, 330 { id: API_REFERENCE_KEY, label: "API Reference" }, 331 ]; 332 333 // Calculate result counts for each filter 334 const resultCounts = useMemo(() => { 335 return { 336 all: results.length, 337 [DOCUMENTATION_KEY]: results.filter( 338 (r) => r.category === DOCUMENTATION_KEY 339 ).length, 340 [API_REFERENCE_KEY]: results.filter( 341 (r) => r.category === API_REFERENCE_KEY 342 ).length, 343 }; 344 }, [results]); 345 346 const getFilterLabel = (filter) => { 347 const count = resultCounts[filter.id]; 348 if (count > 0 && query.length >= MIN_QUERY_LENGTH) { 349 return `${filter.label} (${count})`; 350 } 351 return filter.label; 352 }; 353 354 return ( 355 <div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}> 356 <div className={styles.searchContainer} role="search"> 357 <button 358 className={styles.searchButton} 359 onClick={() => setShowModal(true)} 360 aria-label="Open search" 361 > 362 <span className={styles.searchIcon}>🔍</span> 363 <span className={styles.searchPlaceholder}> 364 Search documentation... 365 </span> 366 </button> 367 </div> 368 369 {showModal && ( 370 <div className={styles.modalOverlay}> 371 <div className={styles.modalContent} ref={modalRef}> 372 <div className={styles.modalHeader}> 373 <div className={styles.searchInputWrapper} ref={searchInputRef}> 374 <span className={styles.searchIconInput}>🔍</span> 375 <input 376 type="text" 377 className={styles.searchInput} 378 placeholder="Search documentation..." 379 aria-label="Search documentation" 380 value={query} 381 onChange={handleInputChange} 382 onKeyDown={handleKeyDown} 383 autoFocus 384 /> 385 {query && ( 386 <button 387 type="button" 388 className={styles.clearButton} 389 aria-label="Clear search" 390 onClick={() => { 391 setQuery(""); 392 setResults([]); 393 setError(null); 394 debouncedSearch.cancel(); 395 if (requestAbortRef.current) 396 requestAbortRef.current.abort(); 397 }} 398 > 399 ✕ 400 </button> 401 )} 402 {isSearching && ( 403 <div 404 className={styles.searchingIndicator} 405 role="status" 406 aria-live="polite" 407 aria-label="Searching" 408 > 409 <span className={styles.spinner} aria-hidden="true"></span> 410 </div> 411 )} 412 </div> 413 414 <div className={styles.filterTabs} role="tablist"> 415 {filters.map((filter) => ( 416 <button 417 key={filter.id} 418 className={`${styles.filterTab} ${ 419 activeFilter === filter.id ? styles.filterTabActive : "" 420 }`} 421 onClick={() => { 422 setActiveFilter(filter.id); 423 }} 424 role="tab" 425 aria-selected={activeFilter === filter.id} 426 > 427 {getFilterLabel(filter)} 428 </button> 429 ))} 430 </div> 431 </div> 432 433 <div className={styles.modalBody}> 434 {!query || query.length < MIN_QUERY_LENGTH ? ( 435 <div className={styles.emptyState}> 436 <span className={styles.emptyStateIcon}>🔍</span> 437 <p className={styles.emptyStateText}> 438 Start typing to search... 439 </p> 440 </div> 441 ) : isSearching ? ( 442 <div className={styles.loadingState}> 443 <span className={styles.spinner}></span> 444 <p>Searching...</p> 445 </div> 446 ) : filteredResults.length > 0 ? ( 447 <div className={styles.searchResults}> 448 <ul> 449 {filteredResults.map((result, index) => ( 450 <li 451 key={index} 452 onClick={() => handleResultClick(result.url)} 453 > 454 <div className={styles.resultTitle}> 455 <a 456 href={result.url} 457 onClick={(e) => { 458 e.preventDefault(); 459 handleResultClick(result.url); 460 }} 461 > 462 {result.title} 463 </a> 464 </div> 465 <div 466 className={styles.resultSnippet} 467 dangerouslySetInnerHTML={{ __html: result.snippet }} 468 /> 469 </li> 470 ))} 471 </ul> 472 </div> 473 ) : ( 474 <div className={styles.noResultsState}> 475 {error ? ( 476 <p className={styles.noResults}>{error}</p> 477 ) : ( 478 <p className={styles.noResults}> 479 No results found for "{query}" 480 {activeFilter !== "all" && ` in ${activeFilter}`} 481 </p> 482 )} 483 </div> 484 )} 485 </div> 486 487 <div className={styles.modalFooter}> 488 <span className={styles.poweredBy}> 489 Powered by{" "} 490 <img 491 src={ 492 isDarkMode 493 ? "/img/haystack-by-deepset-light.png" 494 : "/img/haystack-by-deepset.png" 495 } 496 alt="Haystack by deepset" 497 className={styles.poweredByLogo} 498 /> 499 </span> 500 </div> 501 </div> 502 </div> 503 )} 504 </div> 505 ); 506 }