diff --git a/docusaurus.config.js b/docusaurus.config.js index 2ced3fc3..97eaf20f 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -120,21 +120,13 @@ const config = { theme: require("./src/internals/prism-github"), darkTheme: require("./src/internals/prism-dracula"), }, - - ...(typeof process.env.ALGOLIA_API_KEY === "string" && - typeof process.env.ALGOLIA_APP_ID === "string" - ? { - algolia: { - appId: process.env.ALGOLIA_APP_ID, - apiKey: process.env.ALGOLIA_API_KEY, - indexName: "questdb", - // Disable /search page - searchPagePath: false, - contextualSearch: false, - externalUrlRegex: 'questdb\\.io', - }, - } - : {}), + algolia: { + appId: process.env.ALGOLIA_APP_ID || 'placeholder-app-id', + apiKey: process.env.ALGOLIA_API_KEY || 'placeholder-api-key', + indexName: "questdb", + searchPagePath: false, + contextualSearch: false, + }, }, presets: [ [ diff --git a/package.json b/package.json index 67b9d088..3fd1cf52 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@docusaurus/faster": "^3.6.3", "@docusaurus/theme-mermaid": "^3.6.3", + "@docusaurus/theme-search-algolia": "^3.6.3", "@headlessui/react": "^2.2.0", "@heroicons/react": "2.2.0", "@mdx-js/react": "3.1.0", diff --git a/src/theme/SearchBar/index.tsx b/src/theme/SearchBar/index.tsx new file mode 100644 index 00000000..5fcd2988 --- /dev/null +++ b/src/theme/SearchBar/index.tsx @@ -0,0 +1,286 @@ +import React, {useCallback, useMemo, useRef, useState} from 'react'; +import {createPortal} from 'react-dom'; +import {DocSearchButton, useDocSearchKeyboardEvents} from '@docsearch/react'; +import Head from '@docusaurus/Head'; +import Link from '@docusaurus/Link'; +import {useHistory} from '@docusaurus/router'; +import { + isRegexpStringMatch, + useSearchLinkCreator, +} from '@docusaurus/theme-common'; +import { + useAlgoliaContextualFacetFilters, + useSearchResultUrlProcessor, +} from '@docusaurus/theme-search-algolia/client'; +import Translate from '@docusaurus/Translate'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import translations from '@theme/SearchTranslations'; + +import type {AutocompleteState} from '@algolia/autocomplete-core'; +import type { + DocSearchModal as DocSearchModalType, + DocSearchModalProps, +} from '@docsearch/react'; +import type { + InternalDocSearchHit, + StoredDocSearchHit, +} from '@docsearch/react/dist/esm/types'; +import type {SearchClient} from 'algoliasearch/lite'; + +type DocSearchProps = Omit< + DocSearchModalProps, + 'onClose' | 'initialScrollY' +> & { + contextualSearch?: string; + externalUrlRegex?: string; + searchPagePath: boolean | string; +}; + +let DocSearchModal: typeof DocSearchModalType | null = null; + +function Hit({ + hit, + children, +}: { + hit: InternalDocSearchHit | StoredDocSearchHit; + children: React.ReactNode; +}) { + // Transform URL once and use it in both places + const transformUrl = (url: string) => { + return (url.startsWith('/docs/glossary/') || url.startsWith('/docs/blog/')) + ? url.replace('/docs/', '/') + : url; + }; + + const finalUrl = transformUrl(hit.url); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + window.location.assign(finalUrl); + }; + + return ( + + {children} + + ); +} + +type ResultsFooterProps = { + state: AutocompleteState; + onClose: () => void; +}; + +function ResultsFooter({state, onClose}: ResultsFooterProps) { + const createSearchLink = useSearchLinkCreator(); + + return ( + + + {'See all {count} results'} + + + ); +} + +type FacetFilters = Required< + Required['searchParameters'] +>['facetFilters']; + +function mergeFacetFilters(f1: FacetFilters, f2: FacetFilters): FacetFilters { + const normalize = ( + f: FacetFilters, + ): readonly string[] | readonly (string | readonly string[])[] => + typeof f === 'string' ? [f] : f; + return [...normalize(f1), ...normalize(f2)] as FacetFilters; +} + +function DocSearch({ + contextualSearch, + externalUrlRegex, + ...props +}: DocSearchProps) { + const {siteMetadata} = useDocusaurusContext(); + const processSearchResultUrl = useSearchResultUrlProcessor(); + + const contextualSearchFacetFilters = + useAlgoliaContextualFacetFilters() as FacetFilters; + + const configFacetFilters: FacetFilters = + props.searchParameters?.facetFilters ?? []; + + const facetFilters: FacetFilters = contextualSearch + ? // Merge contextual search filters with config filters + mergeFacetFilters(contextualSearchFacetFilters, configFacetFilters) + : // ... or use config facetFilters + configFacetFilters; + + // We let user override default searchParameters if she wants to + const searchParameters: DocSearchProps['searchParameters'] = { + ...props.searchParameters, + facetFilters, + }; + + const history = useHistory(); + const searchContainer = useRef(null); + const searchButtonRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [initialQuery, setInitialQuery] = useState( + undefined, + ); + + const importDocSearchModalIfNeeded = useCallback(() => { + if (DocSearchModal) { + return Promise.resolve(); + } + + return Promise.all([ + import('@docsearch/react/modal') as Promise< + typeof import('@docsearch/react') + >, + import('@docsearch/react/style'), + import('./styles.css'), + ]).then(([{DocSearchModal: Modal}]) => { + DocSearchModal = Modal; + }); + }, []); + + const prepareSearchContainer = useCallback(() => { + if (!searchContainer.current) { + const divElement = document.createElement('div'); + searchContainer.current = divElement; + document.body.insertBefore(divElement, document.body.firstChild); + } + }, []); + + const openModal = useCallback(() => { + prepareSearchContainer(); + importDocSearchModalIfNeeded().then(() => setIsOpen(true)); + }, [importDocSearchModalIfNeeded, prepareSearchContainer]); + + const closeModal = useCallback(() => { + setIsOpen(false); + searchButtonRef.current?.focus(); + }, []); + + const handleInput = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'f' && (event.metaKey || event.ctrlKey)) { + // ignore browser's ctrl+f + return; + } + // prevents duplicate key insertion in the modal input + event.preventDefault(); + setInitialQuery(event.key); + openModal(); + }, + [openModal], + ); + + const navigator = useRef({ + navigate({itemUrl}: {itemUrl?: string}) { + // Algolia results could contain URL's from other domains which cannot + // be served through history and should navigate with window.location + if (isRegexpStringMatch(externalUrlRegex, itemUrl)) { + window.location.href = itemUrl!; + } else { + history.push(itemUrl!); + } + }, + }).current; + + const transformItems = useRef( + (items) => + props.transformItems + ? // Custom transformItems + props.transformItems(items) + : // Default transformItems + items.map((item) => ({ + ...item, + url: processSearchResultUrl(item.url), + })), + ).current; + + const resultsFooterComponent: DocSearchProps['resultsFooterComponent'] = + useMemo( + () => + // eslint-disable-next-line react/no-unstable-nested-components + (footerProps: Omit): JSX.Element => + , + [closeModal], + ); + + const transformSearchClient = useCallback( + (searchClient: SearchClient) => { + searchClient.addAlgoliaAgent( + 'docusaurus', + siteMetadata.docusaurusVersion, + ); + + return searchClient; + }, + [siteMetadata.docusaurusVersion], + ); + + useDocSearchKeyboardEvents({ + isOpen, + onOpen: openModal, + onClose: closeModal, + onInput: handleInput, + searchButtonRef, + }); + + return ( + <> + + {/* This hints the browser that the website will load data from Algolia, + and allows it to preconnect to the DocSearch cluster. It makes the first + query faster, especially on mobile. */} + + + + + + {isOpen && + DocSearchModal && + searchContainer.current && + createPortal( + , + searchContainer.current, + )} + + ); +} + +export default function SearchBar(): JSX.Element { + const {siteConfig} = useDocusaurusContext(); + return ; +} diff --git a/src/theme/SearchBar/styles.css b/src/theme/SearchBar/styles.css new file mode 100644 index 00000000..fdf8dff9 --- /dev/null +++ b/src/theme/SearchBar/styles.css @@ -0,0 +1,14 @@ +:root { + --docsearch-primary-color: var(--ifm-color-primary); + --docsearch-text-color: var(--ifm-font-color-base); +} + +.DocSearch-Button { + margin: 0; + transition: all var(--ifm-transition-fast) + var(--ifm-transition-timing-default); +} + +.DocSearch-Container { + z-index: calc(var(--ifm-z-index-fixed) + 1); +} diff --git a/yarn.lock b/yarn.lock index 5d65c4af..0b72e9a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1999,7 +1999,7 @@ mermaid ">=10.4" tslib "^2.6.0" -"@docusaurus/theme-search-algolia@3.6.3": +"@docusaurus/theme-search-algolia@3.6.3", "@docusaurus/theme-search-algolia@^3.6.3": version "3.6.3" resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.6.3.tgz#1a3331a489f392f5b032c4efc5f431e57eddf7ce" integrity sha512-rt+MGCCpYgPyWCGXtbxlwFbTSobu15jWBTPI2LHsHNa5B0zSmOISX6FWYAPt5X1rNDOqMGM0FATnh7TBHRohVA==