/** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ /* eslint-disable jsx-a11y/no-autofocus */ import React, {useEffect, useState, useReducer, useRef} from 'react'; import clsx from 'clsx'; import algoliaSearchHelper from 'algoliasearch-helper'; import type {SearchParameters} from 'algoliasearch-helper'; import Head from '@docusaurus/Head'; import Link from '@docusaurus/Link'; import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; import { HtmlClassNameProvider, usePluralForm, isRegexpStringMatch, useEvent, // @ts-ignore } from '@docusaurus/theme-common'; import {useSearchPage} from '../../hooks/useSearchPage'; // @ts-ignore import {useTitleFormatter} from '../../utils/generalUtils'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import {useAllDocsData} from '@docusaurus/plugin-content-docs/client'; import Translate, {translate} from '@docusaurus/Translate'; import Layout from '@theme/Layout'; import styles from './styles.module.css'; import type {ThemeConfig} from 'docusaurus-theme-search-typesense'; import TypesenseInstantSearchAdapter from 'typesense-instantsearch-adapter'; // Very simple pluralization: probably good enough for now function useDocumentsFoundPlural() { const {selectMessage} = usePluralForm(); return (count: number) => selectMessage( count, translate( { id: 'theme.SearchPage.documentsFound.plurals', description: 'Pluralized label for "{count} documents found". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)', message: 'One document found|{count} documents found', }, {count}, ), ); } function useDocsSearchVersionsHelpers() { const allDocsData = useAllDocsData(); // State of the version select menus / algolia facet filters // docsPluginId -> versionName map const [searchVersions, setSearchVersions] = useState<{ [pluginId: string]: string; }>(() => Object.entries(allDocsData).reduce( (acc, [pluginId, pluginData]) => ({ ...acc, [pluginId]: pluginData.versions[0]!.name, }), {}, ), ); // Set the value of a single select menu const setSearchVersion = (pluginId: string, searchVersion: string) => setSearchVersions((s) => ({...s, [pluginId]: searchVersion})); const versioningEnabled = Object.values(allDocsData).some( (docsData) => docsData.versions.length > 1, ); return { allDocsData, versioningEnabled, searchVersions, setSearchVersion, }; } // We want to display one select per versioned docs plugin instance function SearchVersionSelectList({ docsSearchVersionsHelpers, }: { docsSearchVersionsHelpers: ReturnType; }) { const versionedPluginEntries = Object.entries( docsSearchVersionsHelpers.allDocsData, ) // Do not show a version select for unversioned docs plugin instances .filter(([, docsData]) => docsData.versions.length > 1); return (
{versionedPluginEntries.map(([pluginId, docsData]) => { const labelPrefix = versionedPluginEntries.length > 1 ? `${pluginId}: ` : ''; return ( ); })}
); } type ResultDispatcherState = { items: { title: string; url: string; summary: string; breadcrumbs: string[]; }[]; query: string | null; totalResults: number | null; totalPages: number | null; lastPage: number | null; hasMore: boolean | null; loading: boolean | null; }; type ResultDispatcher = | {type: 'reset'; value?: undefined} | {type: 'loading'; value?: undefined} | {type: 'update'; value: ResultDispatcherState} | {type: 'advance'; value?: undefined}; function SearchPageContent(): JSX.Element { const { siteConfig: {themeConfig}, i18n: {currentLocale}, } = useDocusaurusContext(); const { typesense: { typesenseCollectionName, typesenseServerConfig, typesenseSearchParameters, externalUrlRegex, }, } = themeConfig as ThemeConfig; const documentsFoundPlural = useDocumentsFoundPlural(); const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers(); const {searchQuery, setSearchQuery} = useSearchPage(); const initialSearchResultState: ResultDispatcherState = { items: [], query: null, totalResults: null, totalPages: null, lastPage: null, hasMore: null, loading: null, }; const [searchResultState, searchResultStateDispatcher] = useReducer( (prevState: ResultDispatcherState, data: ResultDispatcher) => { switch (data.type) { case 'reset': { return initialSearchResultState; } case 'loading': { return {...prevState, loading: true}; } case 'update': { if (searchQuery !== data.value.query) { return prevState; } return { ...data.value, items: data.value.lastPage === 0 ? data.value.items : prevState.items.concat(data.value.items), }; } case 'advance': { const hasMore = prevState.totalPages! > prevState.lastPage! + 1; return { ...prevState, lastPage: hasMore ? prevState.lastPage! + 1 : prevState.lastPage, hasMore, }; } default: return prevState; } }, initialSearchResultState, ); const typesenseInstantSearchAdapter = new TypesenseInstantSearchAdapter({ server: typesenseServerConfig, additionalSearchParameters: { query_by: 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,hierarchy.lvl6,content', include_fields: 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,hierarchy.lvl6,content,anchor,url,type,id', highlight_full_fields: 'hierarchy.lvl0,hierarchy.lvl1,hierarchy.lvl2,hierarchy.lvl3,hierarchy.lvl4,hierarchy.lvl5,hierarchy.lvl6,content', group_by: 'url', group_limit: 3, highlight_affix_num_tokens: 50, ...typesenseSearchParameters, }, }); // Needed this to avoid a typescript error in algoliaSearchHelper const searchParams: Partial = { hitsPerPage: 15, advancedSyntax: true, disjunctiveFacets: ['language', 'docusaurus_tag'], }; const algoliaHelper = algoliaSearchHelper( typesenseInstantSearchAdapter.searchClient, typesenseCollectionName, searchParams, ); algoliaHelper.on( 'result', ({results: {query, hits, page, nbHits, nbPages}}) => { if (query === '' || !Array.isArray(hits)) { searchResultStateDispatcher({type: 'reset'}); return; } const sanitizeValue = (value: string) => value.replace( /algolia-docsearch-suggestion--highlight/g, 'search-result-match', ); const items = hits.map( ({ url, _highlightResult, _snippetResult: snippet = {}, }: { url: string; _highlightResult: {[key: string]: {value: string}}; _snippetResult: {content?: {value: string}}; }) => { const parsedURL = new URL(url); const titles = [0, 1, 2, 3, 4, 5, 6] .map((lvl) => { const highlightResult = _highlightResult[`hierarchy.lvl${lvl}`]; return highlightResult ? sanitizeValue(highlightResult.value) : ''; }) .filter((v) => v); return { title: titles.pop()!, url: isRegexpStringMatch(externalUrlRegex, parsedURL.href) ? parsedURL.href : parsedURL.pathname + parsedURL.hash, summary: snippet.content ? `${sanitizeValue(snippet.content.value)}...` : '', breadcrumbs: titles, }; }, ); searchResultStateDispatcher({ type: 'update', value: { items, query, totalResults: nbHits, totalPages: nbPages, lastPage: page, hasMore: nbPages > page + 1, loading: false, }, }); }, ); algoliaHelper.on('error', function (e) { console.error(e); }); const [loaderRef, setLoaderRef] = useState(null); const prevY = useRef(0); const observer = useRef( ExecutionEnvironment.canUseIntersectionObserver && new IntersectionObserver( (entries) => { const { isIntersecting, boundingClientRect: {y: currentY}, } = entries[0]!; if (isIntersecting && prevY.current > currentY) { searchResultStateDispatcher({type: 'advance'}); } prevY.current = currentY; }, {threshold: 1}, ), ); const getTitle = () => searchQuery ? translate( { id: 'theme.SearchPage.existingResultsTitle', message: 'Search results for "{query}"', description: 'The search page title for non-empty query', }, { query: searchQuery, }, ) : translate({ id: 'theme.SearchPage.emptyResultsTitle', message: 'Search the documentation', description: 'The search page title for empty query', }); const makeSearch = useEvent((page: number = 0) => { algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', 'default'); algoliaHelper.addDisjunctiveFacetRefinement('language', currentLocale); Object.entries(docsSearchVersionsHelpers.searchVersions).forEach( ([pluginId, searchVersion]) => { algoliaHelper.addDisjunctiveFacetRefinement( 'docusaurus_tag', `docs-${pluginId}-${searchVersion}`, ); }, ); algoliaHelper.setQuery(searchQuery).setPage(page).search(); }); useEffect(() => { if (!loaderRef) { return undefined; } const currentObserver = observer.current; if (currentObserver) { currentObserver.observe(loaderRef); return () => currentObserver.unobserve(loaderRef); } return () => true; }, [loaderRef]); useEffect(() => { searchResultStateDispatcher({type: 'reset'}); if (searchQuery) { searchResultStateDispatcher({type: 'loading'}); setTimeout(() => { makeSearch(); }, 300); } }, [searchQuery, docsSearchVersionsHelpers.searchVersions, makeSearch]); useEffect(() => { if (!searchResultState.lastPage || searchResultState.lastPage === 0) { return; } makeSearch(searchResultState.lastPage); }, [makeSearch, searchResultState.lastPage]); return ( {useTitleFormatter(getTitle())} {/* We should not index search pages See https://github.com/facebook/docusaurus/pull/3233 */}

{getTitle()}

e.preventDefault()}>
setSearchQuery(e.target.value)} value={searchQuery} autoComplete="off" autoFocus />
{docsSearchVersionsHelpers.versioningEnabled && ( )}
{!!searchResultState.totalResults && documentsFoundPlural(searchResultState.totalResults)}
{searchResultState.items.length > 0 ? (
{searchResultState.items.map( ({title, url, summary, breadcrumbs}, i) => (

{breadcrumbs.length > 0 && ( )} {summary && (

)}

), )}
) : ( [ searchQuery && !searchResultState.loading && (

No results were found

), !!searchResultState.loading && (
), ] )} {searchResultState.hasMore && (
Fetching new results...
)}
); } export default function SearchPage(): JSX.Element { return ( ); }