From 3235b60ddaf68f7bfa29888b9a7e2843fee30293 Mon Sep 17 00:00:00 2001 From: jrandolf <101637635+jrandolf@users.noreply.github.com> Date: Fri, 16 Dec 2022 17:26:01 +0100 Subject: [PATCH] docs: use counter for algolia search (#9428) The component was [swizzled](https://docusaurus.io/docs/swizzling) from the Algolia plugin. The main logic that was changed is on lines 263 to 272. --- website/src/theme/SearchPage/index.js | 488 ++++++++++++++++++ .../src/theme/SearchPage/styles.module.css | 112 ++++ 2 files changed, 600 insertions(+) create mode 100644 website/src/theme/SearchPage/index.js create mode 100644 website/src/theme/SearchPage/styles.module.css diff --git a/website/src/theme/SearchPage/index.js b/website/src/theme/SearchPage/index.js new file mode 100644 index 00000000..dd2cd539 --- /dev/null +++ b/website/src/theme/SearchPage/index.js @@ -0,0 +1,488 @@ +import React, {useEffect, useState, useReducer, useRef} from 'react'; +import clsx from 'clsx'; +import algoliaSearch from 'algoliasearch/lite'; +import algoliaSearchHelper from 'algoliasearch-helper'; +import Head from '@docusaurus/Head'; +import Link from '@docusaurus/Link'; +import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; +import { + HtmlClassNameProvider, + usePluralForm, + isRegexpStringMatch, + useEvent, +} from '@docusaurus/theme-common'; +import { + useTitleFormatter, + useSearchPage, +} from '@docusaurus/theme-common/internal'; +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'; +// Very simple pluralization: probably good enough for now +function useDocumentsFoundPlural() { + const {selectMessage} = usePluralForm(); + return count => { + return 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(() => { + return Object.entries(allDocsData).reduce((acc, [pluginId, pluginData]) => { + return { + ...acc, + [pluginId]: pluginData.versions[0].name, + }; + }, {}); + }); + // Set the value of a single select menu + const setSearchVersion = (pluginId, searchVersion) => { + return setSearchVersions(s => { + return {...s, [pluginId]: searchVersion}; + }); + }; + const versioningEnabled = Object.values(allDocsData).some(docsData => { + return 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]) => { + return docsData.versions.length > 1; + }); + return ( +
+ {versionedPluginEntries.map(([pluginId, docsData]) => { + const labelPrefix = + versionedPluginEntries.length > 1 ? `${pluginId}: ` : ''; + return ( + + ); + })} +
+ ); +} +function SearchPageContent() { + const { + siteConfig: {themeConfig}, + i18n: {currentLocale}, + } = useDocusaurusContext(); + const { + algolia: {appId, apiKey, indexName, externalUrlRegex}, + } = themeConfig; + const documentsFoundPlural = useDocumentsFoundPlural(); + const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers(); + const {searchQuery, setSearchQuery} = useSearchPage(); + 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 + ); + const algoliaClient = algoliaSearch(appId, apiKey); + const algoliaHelper = algoliaSearchHelper(algoliaClient, indexName, { + hitsPerPage: 15, + advancedSyntax: true, + disjunctiveFacets: ['language', 'counter'], + }); + algoliaHelper.on( + 'result', + ({results: {query, hits, page, nbHits, nbPages}}) => { + if (query === '' || !Array.isArray(hits)) { + searchResultStateDispatcher({type: 'reset'}); + return; + } + const sanitizeValue = value => { + return value.replace( + /algolia-docsearch-suggestion--highlight/g, + 'search-result-match' + ); + }; + const items = hits.map( + ({ + url, + _highlightResult: {hierarchy}, + _snippetResult: snippet = {}, + }) => { + const parsedURL = new URL(url); + const titles = Object.keys(hierarchy).map(key => { + return sanitizeValue(hierarchy[key].value); + }); + 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, + }, + }); + } + ); + 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 = () => { + return 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) => { + algoliaHelper.addDisjunctiveFacetRefinement( + 'counter', + document + .querySelector('meta[name="docsearch:counter"]') + .getAttribute('content') + ); + algoliaHelper.addDisjunctiveFacetRefinement('language', currentLocale); + algoliaHelper.setQuery(searchQuery).setPage(page).search(); + }); + useEffect(() => { + if (!loaderRef) { + return undefined; + } + const currentObserver = observer.current; + if (currentObserver) { + currentObserver.observe(loaderRef); + return () => { + return currentObserver.unobserve(loaderRef); + }; + } + return () => { + 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()}

+ +
{ + return e.preventDefault(); + }} + > +
+ { + return 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) => { + return ( +
+

+ +

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

+ )} +

+ ); + } + )} +
+ ) : ( + [ + searchQuery && !searchResultState.loading && ( +

+ + No results were found + +

+ ), + !!searchResultState.loading && ( +
+ ), + ] + )} + + {searchResultState.hasMore && ( +
+ + Fetching new results... + +
+ )} +
+ + ); +} +export default function SearchPage() { + return ( + + + + ); +} diff --git a/website/src/theme/SearchPage/styles.module.css b/website/src/theme/SearchPage/styles.module.css new file mode 100644 index 00000000..57de7498 --- /dev/null +++ b/website/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; +}