/ docs-website / src / theme / SearchBar.js
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                    >
399400                    </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  }