From 5e5647b5dd03f53eb9563375eb1789b3f7399bc4 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Wed, 23 Oct 2024 16:18:21 -0700 Subject: [PATCH] docs: render api ref urls in search (#27594) --- docs/src/theme/SearchBar/index.js | 202 +++++++++ docs/src/theme/SearchBar/styles.css | 14 + docs/src/theme/SearchPage/index.js | 464 ++++++++++++++++++++ docs/src/theme/SearchPage/styles.module.css | 112 +++++ 4 files changed, 792 insertions(+) create mode 100644 docs/src/theme/SearchBar/index.js create mode 100644 docs/src/theme/SearchBar/styles.css create mode 100644 docs/src/theme/SearchPage/index.js create mode 100644 docs/src/theme/SearchPage/styles.module.css diff --git a/docs/src/theme/SearchBar/index.js b/docs/src/theme/SearchBar/index.js new file mode 100644 index 00000000000..923a9cfac86 --- /dev/null +++ b/docs/src/theme/SearchBar/index.js @@ -0,0 +1,202 @@ +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'; +let DocSearchModal = null; +function Hit({hit, children}) { + if (hit.url.includes('/api_reference/')) { + return ( + + {children} + + ) + } + return {children}; +} +function ResultsFooter({state, onClose}) { + const createSearchLink = useSearchLinkCreator(); + return ( + + + {'See all {count} results'} + + + ); +} +function mergeFacetFilters(f1, f2) { + const normalize = (f) => (typeof f === 'string' ? [f] : f); + return [...normalize(f1), ...normalize(f2)]; +} +function DocSearch({contextualSearch, externalUrlRegex, ...props}) { + const {siteMetadata} = useDocusaurusContext(); + const processSearchResultUrl = useSearchResultUrlProcessor(); + const contextualSearchFacetFilters = useAlgoliaContextualFacetFilters(); + const configFacetFilters = props.searchParameters?.facetFilters ?? []; + const 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 = { + ...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'), + 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) => { + 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}) { + // 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) || itemUrl.includes('/api_reference/')) { + 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 = useMemo( + () => + // eslint-disable-next-line react/no-unstable-nested-components + (footerProps) => + , + [closeModal], + ); + const transformSearchClient = useCallback( + (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() { + const {siteConfig} = useDocusaurusContext(); + return ; +} diff --git a/docs/src/theme/SearchBar/styles.css b/docs/src/theme/SearchBar/styles.css new file mode 100644 index 00000000000..fdf8dff9a4a --- /dev/null +++ b/docs/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/docs/src/theme/SearchPage/index.js b/docs/src/theme/SearchPage/index.js new file mode 100644 index 00000000000..7a5da716737 --- /dev/null +++ b/docs/src/theme/SearchPage/index.js @@ -0,0 +1,464 @@ +/* eslint-disable jsx-a11y/no-autofocus */ +import React, {useEffect, useReducer, useRef, useState} from 'react'; +import clsx from 'clsx'; +import algoliaSearchHelper from 'algoliasearch-helper'; +import algoliaSearch from 'algoliasearch/lite'; +import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; +import Head from '@docusaurus/Head'; +import Link from '@docusaurus/Link'; +import {useAllDocsData} from '@docusaurus/plugin-content-docs/client'; +import { + HtmlClassNameProvider, + useEvent, + usePluralForm, + useSearchQueryString, +} from '@docusaurus/theme-common'; +import {useTitleFormatter} from '@docusaurus/theme-common/internal'; +import Translate, {translate} from '@docusaurus/Translate'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { + useAlgoliaThemeConfig, + useSearchResultUrlProcessor, +} from '@docusaurus/theme-search-algolia/client'; +import Layout from '@theme/Layout'; +import Heading from '@theme/Heading'; +import styles from './styles.module.css'; +// Very simple pluralization: probably good enough for now +function useDocumentsFoundPlural() { + const {selectMessage} = usePluralForm(); + return (count) => + 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(() => + Object.entries(allDocsData).reduce( + (acc, [pluginId, pluginData]) => ({ + ...acc, + [pluginId]: pluginData.versions[0].name, + }), + {}, + ), + ); + // Set the value of a single select menu + const setSearchVersion = (pluginId, searchVersion) => + 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}) { + 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 ( + + ); + })} +
+ ); +} +function SearchPageContent() { + const { + i18n: {currentLocale}, + } = useDocusaurusContext(); + const { + algolia: {appId, apiKey, indexName, contextualSearch}, + } = useAlgoliaThemeConfig(); + const processSearchResultUrl = useSearchResultUrlProcessor(); + const documentsFoundPlural = useDocumentsFoundPlural(); + const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers(); + const [searchQuery, setSearchQuery] = useSearchQueryString(); + const initialSearchResultState = { + items: [], + query: null, + totalResults: null, + totalPages: null, + lastPage: null, + hasMore: null, + loading: null, + }; + const [searchResultState, searchResultStateDispatcher] = useReducer( + (prevState, data) => { + 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, + ); + // respect settings from the theme config for facets + const disjunctiveFacets = contextualSearch + ? ['language', 'docusaurus_tag'] + : []; + const algoliaClient = algoliaSearch(appId, apiKey); + const algoliaHelper = algoliaSearchHelper(algoliaClient, indexName, { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: why errors happens after upgrading to TS 5.5 ? + hitsPerPage: 15, + advancedSyntax: true, + disjunctiveFacets, + }); + algoliaHelper.on( + 'result', + ({results: {query, hits, page, nbHits, nbPages}}) => { + if (query === '' || !Array.isArray(hits)) { + searchResultStateDispatcher({type: 'reset'}); + return; + } + const sanitizeValue = (value) => + value.replace( + /algolia-docsearch-suggestion--highlight/g, + 'search-result-match', + ); + const items = hits.map( + ({ + url, + _highlightResult: {hierarchy}, + _snippetResult: snippet = {}, + }) => { + const titles = Object.keys(hierarchy).map((key) => + sanitizeValue(hierarchy[key].value), + ); + return { + title: titles.pop(), + url: processSearchResultUrl(url), + 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, + }, + }); + }, + ); + 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 = 0) => { + if (contextualSearch) { + 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 + /> +
+ + {contextualSearch && docsSearchVersionsHelpers.versioningEnabled && ( + + )} + + +
+
+ {!!searchResultState.totalResults && + documentsFoundPlural(searchResultState.totalResults)} +
+ +
+ + + + + + + + + +
+
+ + {searchResultState.items.length > 0 ? ( +
+ {searchResultState.items.map( + ({title, url, summary, breadcrumbs}, i) => ( + + ), + )} +
+ ) : ( + [ + searchQuery && !searchResultState.loading && ( +

+ + No results were found + +

+ ), + !!searchResultState.loading && ( +
+ ), + ] + )} + + {searchResultState.hasMore && ( +
+ + Fetching new results... + +
+ )} +
+ + ); +} +export default function SearchPage() { + return ( + + + + ); +} diff --git a/docs/src/theme/SearchPage/styles.module.css b/docs/src/theme/SearchPage/styles.module.css new file mode 100644 index 00000000000..57de7498db6 --- /dev/null +++ b/docs/src/theme/SearchPage/styles.module.css @@ -0,0 +1,112 @@ +.searchQueryInput, +.searchVersionInput { + border-radius: var(--ifm-global-radius); + border: 2px solid var(--ifm-toc-border-color); + font: var(--ifm-font-size-base) var(--ifm-font-family-base); + padding: 0.8rem; + width: 100%; + background: var(--docsearch-searchbox-focus-background); + color: var(--docsearch-text-color); + margin-bottom: 0.5rem; + transition: border var(--ifm-transition-fast) ease; +} + +.searchQueryInput:focus, +.searchVersionInput:focus { + border-color: var(--docsearch-primary-color); + outline: none; +} + +.searchQueryInput::placeholder { + color: var(--docsearch-muted-color); +} + +.searchResultsColumn { + font-size: 0.9rem; + font-weight: bold; +} + +.algoliaLogo { + max-width: 150px; +} + +.algoliaLogoPathFill { + fill: var(--ifm-font-color-base); +} + +.searchResultItem { + padding: 1rem 0; + border-bottom: 1px solid var(--ifm-toc-border-color); +} + +.searchResultItemHeading { + font-weight: 400; + margin-bottom: 0; +} + +.searchResultItemPath { + font-size: 0.8rem; + color: var(--ifm-color-content-secondary); + --ifm-breadcrumb-separator-size-multiplier: 1; +} + +.searchResultItemSummary { + margin: 0.5rem 0 0; + font-style: italic; +} + +@media only screen and (max-width: 996px) { + .searchQueryColumn { + max-width: 60% !important; + } + + .searchVersionColumn { + max-width: 40% !important; + } + + .searchResultsColumn { + max-width: 60% !important; + } + + .searchLogoColumn { + max-width: 40% !important; + padding-left: 0 !important; + } +} + +@media screen and (max-width: 576px) { + .searchQueryColumn { + max-width: 100% !important; + } + + .searchVersionColumn { + max-width: 100% !important; + padding-left: var(--ifm-spacing-horizontal) !important; + } +} + +.loadingSpinner { + width: 3rem; + height: 3rem; + border: 0.4em solid #eee; + border-top-color: var(--ifm-color-primary); + border-radius: 50%; + animation: loading-spin 1s linear infinite; + margin: 0 auto; +} + +@keyframes loading-spin { + 100% { + transform: rotate(360deg); + } +} + +.loader { + margin-top: 2rem; +} + +:global(.search-result-match) { + color: var(--docsearch-hit-color); + background: rgb(255 215 142 / 25%); + padding: 0.09em 0; +}