feat(v2): contextual search, dynamic Algolia facetFilters (#3550)

* POC of contextual search dynamic filters

* fix useSearchTags bugs

* contextual search should use preferred version  (persisted in storage)

* Contextual search: make system decoupled from algolia + wire proper meta tags and facet filters

* rework doc tag + minor refactorings

* update snapshots

* polish contextual search

* add Algolia validateThemeConfig tests
This commit is contained in:
Sébastien Lorber 2020-10-15 12:16:30 +02:00 committed by GitHub
parent d23940c9c3
commit 21264f5ed0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 468 additions and 138 deletions

View file

@ -302,6 +302,7 @@ Object {
\\"version\\": \\"current\\"
}",
"version-current-metadata-prop-751.json": "{
\\"pluginId\\": \\"default\\",
\\"version\\": \\"current\\",
\\"label\\": \\"Next\\",
\\"isLast\\": true,
@ -640,6 +641,7 @@ Object {
\\"sidebar\\": \\"version-1.0.0/community\\"
}",
"version-1-0-0-metadata-prop-608.json": "{
\\"pluginId\\": \\"community\\",
\\"version\\": \\"1.0.0\\",
\\"label\\": \\"1.0.0\\",
\\"isLast\\": true,
@ -657,6 +659,7 @@ Object {
}
}",
"version-current-metadata-prop-751.json": "{
\\"pluginId\\": \\"community\\",
\\"version\\": \\"current\\",
\\"label\\": \\"Next\\",
\\"isLast\\": false,
@ -1102,6 +1105,7 @@ Object {
\\"version\\": \\"withSlugs\\"
}",
"version-1-0-0-metadata-prop-608.json": "{
\\"pluginId\\": \\"default\\",
\\"version\\": \\"1.0.0\\",
\\"label\\": \\"1.0.0\\",
\\"isLast\\": false,
@ -1145,6 +1149,7 @@ Object {
}
}",
"version-1-0-1-metadata-prop-e87.json": "{
\\"pluginId\\": \\"default\\",
\\"version\\": \\"1.0.1\\",
\\"label\\": \\"1.0.1\\",
\\"isLast\\": true,
@ -1182,6 +1187,7 @@ Object {
}
}",
"version-current-metadata-prop-751.json": "{
\\"pluginId\\": \\"default\\",
\\"version\\": \\"current\\",
\\"label\\": \\"Next\\",
\\"isLast\\": false,
@ -1219,6 +1225,7 @@ Object {
}
}",
"version-with-slugs-metadata-prop-2bf.json": "{
\\"pluginId\\": \\"default\\",
\\"version\\": \\"withSlugs\\",
\\"label\\": \\"withSlugs\\",
\\"isLast\\": false,

View file

@ -267,7 +267,11 @@ export default function pluginContentDocs(
`${docuHash(
`version-${loadedVersion.versionName}-metadata-prop`,
)}.json`,
JSON.stringify(toVersionMetadataProp(loadedVersion), null, 2),
JSON.stringify(
toVersionMetadataProp(pluginId, loadedVersion),
null,
2,
),
);
addRoute({

View file

@ -13,6 +13,7 @@ declare module '@docusaurus/plugin-content-docs-types' {
};
export type PropVersionMetadata = {
pluginId: string;
version: string;
label: string;
isLast: boolean;

View file

@ -62,9 +62,11 @@ Available document ids=
}
export function toVersionMetadataProp(
pluginId: string,
loadedVersion: LoadedVersion,
): PropVersionMetadata {
return {
pluginId,
version: loadedVersion.versionName,
label: loadedVersion.versionLabel,
isLast: loadedVersion.isLast,

View file

@ -19,6 +19,7 @@ import {
getActiveDocContext,
getDocVersionSuggestions,
GetActivePluginOptions,
ActivePlugin,
} from '../../client/docsClientUtils';
export const useAllDocsData = (): Record<string, GlobalPluginData> =>
@ -33,6 +34,23 @@ export const useActivePlugin = (options: GetActivePluginOptions = {}) => {
return getActivePlugin(data, pathname, options);
};
export const useActivePluginAndVersion = (
options: GetActivePluginOptions = {},
):
| undefined
| {activePlugin: ActivePlugin; activeVersion: GlobalVersion | undefined} => {
const activePlugin = useActivePlugin(options);
const {pathname} = useLocation();
if (activePlugin) {
const activeVersion = getActiveVersion(activePlugin.pluginData, pathname);
return {
activePlugin,
activeVersion,
};
}
return undefined;
};
// versions are returned ordered (most recent first)
export const useVersions = (pluginId: string | undefined): GlobalVersion[] => {
const data = useDocsData(pluginId);

View file

@ -18,9 +18,9 @@ import NotFound from '@theme/NotFound';
import type {DocumentRoute} from '@theme/DocItem';
import type {Props} from '@theme/DocPage';
import {matchPath} from '@docusaurus/router';
import Head from '@docusaurus/Head';
import styles from './styles.module.css';
import {docVersionSearchTag} from '../../utils/searchUtils';
type DocPageContentProps = {
readonly currentDocRoute: DocumentRoute;
@ -28,43 +28,22 @@ type DocPageContentProps = {
readonly children: ReactNode;
};
// This theme is not coupled to Algolia, but can we do something else?
// Note the last version is also indexed with "last", to avoid breaking search on new releases
// See https://github.com/facebook/docusaurus/issues/3391
function DocSearchVersionHeader({
version,
isLast,
}: {
version: string;
isLast: boolean;
}) {
const versions = isLast ? [version, 'latest'] : [version];
return (
<Head>
<meta
name="docsearch:version"
content={
// See https://github.com/facebook/docusaurus/issues/3391#issuecomment-685594160
versions.join(',')
}
/>
</Head>
);
}
function DocPageContent({
currentDocRoute,
versionMetadata,
children,
}: DocPageContentProps): JSX.Element {
const {siteConfig, isClient} = useDocusaurusContext();
const {permalinkToSidebar, docsSidebars, version, isLast} = versionMetadata;
const {pluginId, permalinkToSidebar, docsSidebars, version} = versionMetadata;
const sidebarName = permalinkToSidebar[currentDocRoute.path];
const sidebar = docsSidebars[sidebarName];
return (
<>
<DocSearchVersionHeader version={version} isLast={isLast} />
<Layout key={isClient}>
<Layout
key={isClient}
searchMetadatas={{
version,
tag: docVersionSearchTag(pluginId, version),
}}>
<div className={styles.docPage}>
{sidebar && (
<div className={styles.docSidebarContainer} role="complementary">
@ -87,7 +66,6 @@ function DocPageContent({
</main>
</div>
</Layout>
</>
);
}

View file

@ -7,99 +7,26 @@
import React from 'react';
import clsx from 'clsx';
import Head from '@docusaurus/Head';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import useBaseUrl from '@docusaurus/useBaseUrl';
import ThemeProvider from '@theme/ThemeProvider';
import UserPreferencesProvider from '@theme/UserPreferencesProvider';
import AnnouncementBar from '@theme/AnnouncementBar';
import Navbar from '@theme/Navbar';
import Footer from '@theme/Footer';
import LayoutProviders from '@theme/LayoutProviders';
import LayoutHead from '@theme/LayoutHead';
import type {Props} from '@theme/Layout';
import './styles.css';
import DocsPreferredVersionContextProvider from '../../utils/docsPreferredVersion/DocsPreferredVersionProvider';
function Providers({children}) {
return (
<ThemeProvider>
<UserPreferencesProvider>
<DocsPreferredVersionContextProvider>
{children}
</DocsPreferredVersionContextProvider>
</UserPreferencesProvider>
</ThemeProvider>
);
}
function Layout(props: Props): JSX.Element {
const {siteConfig} = useDocusaurusContext();
const {
favicon,
title: siteTitle,
themeConfig: {image: defaultImage, metadatas},
url: siteUrl,
titleDelimiter,
} = siteConfig;
const {
children,
title,
noFooter,
description,
image,
keywords,
permalink,
wrapperClassName,
} = props;
const metaTitle = title
? `${title} ${titleDelimiter} ${siteTitle}`
: siteTitle;
const metaImage = image || defaultImage;
const metaImageUrl = useBaseUrl(metaImage, {absolute: true});
const faviconUrl = useBaseUrl(favicon);
const {children, noFooter, wrapperClassName} = props;
return (
<Providers>
<Head>
{/* TODO: Do not assume that it is in english language */}
<html lang="en" />
{metaTitle && <title>{metaTitle}</title>}
{metaTitle && <meta property="og:title" content={metaTitle} />}
{favicon && <link rel="shortcut icon" href={faviconUrl} />}
{description && <meta name="description" content={description} />}
{description && (
<meta property="og:description" content={description} />
)}
{keywords && keywords.length && (
<meta name="keywords" content={keywords.join(',')} />
)}
{metaImage && <meta property="og:image" content={metaImageUrl} />}
{metaImage && <meta property="twitter:image" content={metaImageUrl} />}
{metaImage && (
<meta name="twitter:image:alt" content={`Image for ${metaTitle}`} />
)}
{permalink && <meta property="og:url" content={siteUrl + permalink} />}
{permalink && <link rel="canonical" href={siteUrl + permalink} />}
<meta name="twitter:card" content="summary_large_image" />
</Head>
<Head
// it's important to have an additional <Head> element here,
// as it allows react-helmet to override values set in previous <Head>
// ie we can override default metadatas such as "twitter:card"
// In same Head, the same meta would appear twice instead of overriding
// See react-helmet doc
>
{metadatas.map((metadata, i) => (
<meta key={`metadata_${i}`} {...metadata} />
))}
</Head>
<LayoutProviders>
<LayoutHead {...props} />
<AnnouncementBar />
<Navbar />
<div className={clsx('main-wrapper', wrapperClassName)}>{children}</div>
{!noFooter && <Footer />}
</Providers>
</LayoutProviders>
);
}

View file

@ -0,0 +1,83 @@
/**
* 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.
*/
import React from 'react';
import Head from '@docusaurus/Head';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import useBaseUrl from '@docusaurus/useBaseUrl';
import type {Props} from '@theme/Layout';
import SearchMetadatas from '@theme/SearchMetadatas';
import {DEFAULT_SEARCH_TAG} from '../../utils/searchUtils';
export default function LayoutHead(props: Props): JSX.Element {
const {siteConfig} = useDocusaurusContext();
const {
favicon,
title: siteTitle,
themeConfig: {image: defaultImage, metadatas},
url: siteUrl,
titleDelimiter,
} = siteConfig;
const {
title,
description,
image,
keywords,
permalink,
searchMetadatas,
} = props;
const metaTitle = title
? `${title} ${titleDelimiter} ${siteTitle}`
: siteTitle;
const metaImage = image || defaultImage;
const metaImageUrl = useBaseUrl(metaImage, {absolute: true});
const faviconUrl = useBaseUrl(favicon);
return (
<>
<Head>
{/* TODO: Do not assume that it is in english language */}
<html lang="en" />
{metaTitle && <title>{metaTitle}</title>}
{metaTitle && <meta property="og:title" content={metaTitle} />}
{favicon && <link rel="shortcut icon" href={faviconUrl} />}
{description && <meta name="description" content={description} />}
{description && (
<meta property="og:description" content={description} />
)}
{keywords && keywords.length && (
<meta name="keywords" content={keywords.join(',')} />
)}
{metaImage && <meta property="og:image" content={metaImageUrl} />}
{metaImage && <meta property="twitter:image" content={metaImageUrl} />}
{metaImage && (
<meta name="twitter:image:alt" content={`Image for ${metaTitle}`} />
)}
{permalink && <meta property="og:url" content={siteUrl + permalink} />}
{permalink && <link rel="canonical" href={siteUrl + permalink} />}
<meta name="twitter:card" content="summary_large_image" />
</Head>
<SearchMetadatas
tag={DEFAULT_SEARCH_TAG}
language="en" // TODO i18n
{...searchMetadatas}
/>
<Head
// it's important to have an additional <Head> element here,
// as it allows react-helmet to override values set in previous <Head>
// ie we can override default metadatas such as "twitter:card"
// In same Head, the same meta would appear twice instead of overriding
// See react-helmet doc
>
{metadatas.map((metadata, i) => (
<meta key={`metadata_${i}`} {...metadata} />
))}
</Head>
</>
);
}

View file

@ -0,0 +1,23 @@
/**
* 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.
*/
import React from 'react';
import ThemeProvider from '@theme/ThemeProvider';
import UserPreferencesProvider from '@theme/UserPreferencesProvider';
import DocsPreferredVersionContextProvider from '../../utils/docsPreferredVersion/DocsPreferredVersionProvider';
export default function LayoutProviders({children}) {
return (
<ThemeProvider>
<UserPreferencesProvider>
<DocsPreferredVersionContextProvider>
{children}
</DocsPreferredVersionContextProvider>
</UserPreferencesProvider>
</ThemeProvider>
);
}

View file

@ -0,0 +1,33 @@
/**
* 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.
*/
import React from 'react';
import Head from '@docusaurus/Head';
type SearchTagMetaProps = {
language?: string;
version?: string;
tag?: string;
};
// Note: we don't couple this to Algolia/DocSearch on purpose
// We may want to support other search engine plugins too
// Search plugins should swizzle/override this comp to add their behavior
export default function SearchMetadatas({
language,
version,
tag,
}: SearchTagMetaProps) {
return (
<Head>
{language && <meta name="docusaurus_language" content={`${language}`} />}
{version && <meta name="docusaurus_version" content={version} />}
{tag && <meta name="docusaurus_tag" content={tag} />}
</Head>
);
}

View file

@ -0,0 +1,49 @@
/**
* 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.
*/
import {useAllDocsData, useActivePluginAndVersion} from '@theme/hooks/useDocs';
import {useDocsPreferredVersionByPluginId} from '../../utils/docsPreferredVersion/useDocsPreferredVersion';
import {DEFAULT_SEARCH_TAG, docVersionSearchTag} from '../../utils/searchUtils';
type ContextualSearchFilters = {
language: string;
tags: string[];
};
// We may want to support multiple search engines, don't couple that to Algolia/DocSearch
// Maybe users will want to use its own search engine solution
export default function useContextualSearchFilters(): ContextualSearchFilters {
const allDocsData = useAllDocsData();
const activePluginAndVersion = useActivePluginAndVersion();
const docsPreferredVersionByPluginId = useDocsPreferredVersionByPluginId();
function getDocPluginTags(pluginId: string) {
const activeVersion =
activePluginAndVersion?.activePlugin?.pluginId === pluginId
? activePluginAndVersion.activeVersion
: undefined;
const preferredVersion = docsPreferredVersionByPluginId[pluginId];
const latestVersion = allDocsData[pluginId].versions.find((v) => v.isLast);
const version = activeVersion ?? preferredVersion ?? latestVersion;
return docVersionSearchTag(pluginId, version.name);
}
const language = 'en'; // TODO i18n
const tags = [
DEFAULT_SEARCH_TAG,
...Object.keys(allDocsData).map(getDocPluginTags),
];
return {
language,
tags,
};
}

View file

@ -246,6 +246,10 @@ declare module '@theme/Layout' {
keywords?: string[];
permalink?: string;
wrapperClassName?: string;
searchMetadatas?: {
version?: string;
tag?: string;
};
};
const Layout: (props: Props) => JSX.Element;

View file

@ -6,7 +6,7 @@
*/
import {useCallback} from 'react';
import {useDocsPreferredVersionContext} from './DocsPreferredVersionProvider';
import {useDocsData} from '@theme/hooks/useDocs';
import {useAllDocsData, useDocsData} from '@theme/hooks/useDocs';
import {DEFAULT_PLUGIN_ID} from '@docusaurus/constants';
@ -32,3 +32,31 @@ export default function useDocsPreferredVersion(
return {preferredVersion, savePreferredVersionName} as const;
}
export function useDocsPreferredVersionByPluginId() {
const allDocsData = useAllDocsData();
const [state] = useDocsPreferredVersionContext();
function getPluginIdPreferredVersion(pluginId: string) {
const docsData = allDocsData[pluginId];
const {preferredVersionName} = state[pluginId];
return preferredVersionName
? docsData.versions.find(
(version) => version.name === preferredVersionName,
)
: null;
}
const pluginIds = Object.keys(allDocsData);
const result: Record<
string,
any // TODO find a way to type this properly!
> = {};
pluginIds.forEach((pluginId) => {
result[pluginId] = getPluginIdPreferredVersion(pluginId);
});
return result;
}

View file

@ -0,0 +1,12 @@
/**
* 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.
*/
export const DEFAULT_SEARCH_TAG = 'default';
export function docVersionSearchTag(pluginId: string, versionName: string) {
return `docs-${pluginId}-${versionName}`;
}

View file

@ -88,4 +88,50 @@ describe('validateThemeConfig', () => {
testValidateThemeConfig({algolia}),
).toThrowErrorMatchingInlineSnapshot(`"\\"algolia.apiKey\\" is required"`);
});
test('contextualSearch config', () => {
const algolia = {
indexName: 'index',
apiKey: 'apiKey',
contextualSearch: true,
};
expect(testValidateThemeConfig({algolia})).toEqual({
algolia: {
...DEFAULT_CONFIG,
...algolia,
},
});
});
test('searchParameters.facetFilters search config', () => {
const algolia = {
indexName: 'index',
apiKey: 'apiKey',
searchParameters: {
facetFilters: ['version:1.0'],
},
};
expect(testValidateThemeConfig({algolia})).toEqual({
algolia: {
...DEFAULT_CONFIG,
...algolia,
},
});
});
test('contextualSearch + searchParameters.facetFilters config', () => {
const algolia = {
indexName: 'index',
apiKey: 'apiKey',
contextualSearch: true,
searchParameters: {
facetFilters: ['version:1.0'],
},
};
expect(() =>
testValidateThemeConfig({algolia}),
).toThrowErrorMatchingInlineSnapshot(
`"If you are using algolia.contextualSearch: true, you should not provide algolia.searchParameters.facetFilters, as it is computed for you dynamically"`,
);
});
});

View file

@ -14,6 +14,7 @@ import Link from '@docusaurus/Link';
import Head from '@docusaurus/Head';
import useSearchQuery from '@theme/hooks/useSearchQuery';
import {DocSearchButton, useDocSearchKeyboardEvents} from '@docsearch/react';
import {useAlgoliaContextualSearchParameters} from '../../utils/algoliaSearchUtils';
let DocSearchModal = null;
@ -31,8 +32,19 @@ function ResultsFooter({state, onClose}) {
);
}
function DocSearch(props) {
function DocSearch({contextualSearch, ...props}) {
const {siteMetadata} = useDocusaurusContext();
const contextualSearchParameters = useAlgoliaContextualSearchParameters();
// we let user override default searchParameters if he wants to
const searchParameters = {
...(contextualSearch ? contextualSearchParameters : {}),
...props.searchParameters,
};
console.log('searchParameters', contextualSearch, searchParameters);
const {withBaseUrl} = useBaseUrlUtils();
const history = useHistory();
const searchButtonRef = useRef(null);
@ -152,6 +164,7 @@ function DocSearch(props) {
resultsFooterComponent={resultsFooterComponent}
transformSearchClient={transformSearchClient}
{...props}
searchParameters={searchParameters}
/>,
document.body,
)}

View file

@ -0,0 +1,21 @@
/**
* 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.
*/
import React from 'react';
import Head from '@docusaurus/Head';
// Override default/agnostic SearchMetas to use Algolia-specific metadatas
export default function AlgoliaSearchMetadatas({language, version, tag}) {
return (
<Head>
{language && <meta name="docsearch:language" content={`${language}`} />}
{version && <meta name="docsearch:version" content={version} />}
{tag && <meta name="docsearch:docusaurus_tag" content={tag} />}
</Head>
);
}

View file

@ -0,0 +1,19 @@
/**
* 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.
*/
import useContextualSearchFilters from '@theme/hooks/useContextualSearchFilters';
// Translate search-engine agnostic seach filters to Algolia search filters
export function useAlgoliaContextualSearchParameters() {
const {language, tags} = useContextualSearchFilters();
const languageFilter = `language:${language}`;
const tagsFilter = tags.map((tag) => `docusaurus_tag:${tag}`);
return {facetFilters: [languageFilter, tagsFilter]};
}

View file

@ -8,17 +8,28 @@
const Joi = require('@hapi/joi');
const DEFAULT_CONFIG = {
contextualSearch: false, // future: maybe we want to enable this by default
// By default, all Docusaurus sites are using the same AppId
// This has been designed on purpose with Algolia.
appId: 'BH4D9OD16A',
searchParameters: {},
};
exports.DEFAULT_CONFIG = DEFAULT_CONFIG;
const Schema = Joi.object({
algolia: Joi.object({
// Docusaurus attributes
contextualSearch: Joi.boolean().default(DEFAULT_CONFIG.contextualSearch),
// Algolia attributes
appId: Joi.string().default(DEFAULT_CONFIG.appId),
apiKey: Joi.string().required(),
indexName: Joi.string().required(),
searchParameters: Joi.object()
.default(DEFAULT_CONFIG.searchParameters)
.unknown(),
})
.label('themeConfig.algolia')
.required()
@ -30,5 +41,17 @@ exports.validateThemeConfig = function validateThemeConfig({
validate,
themeConfig,
}) {
return validate(Schema, themeConfig);
const normalizedThemeConfig = validate(Schema, themeConfig);
if (
normalizedThemeConfig &&
normalizedThemeConfig.algolia.contextualSearch &&
normalizedThemeConfig.algolia.searchParameters &&
normalizedThemeConfig.algolia.searchParameters.facetFilters
) {
throw new Error(
'If you are using algolia.contextualSearch: true, you should not provide algolia.searchParameters.facetFilters, as it is computed for you dynamically',
);
}
return normalizedThemeConfig;
};

View file

@ -27,7 +27,14 @@ module.exports = {
algolia: {
apiKey: 'YOUR_API_KEY',
indexName: 'YOUR_INDEX_NAME',
searchParameters: {}, // Optional (if provided by Algolia)
// Optional: see doc section bellow
contextualSearch: true,
// Optional: Algolia search parameters
searchParameters: {},
//... other Algolia params
},
// highlight-end
},
@ -40,6 +47,37 @@ The `searchParameters` option used to be named `algoliaOptions` in Docusaurus v1
:::
### Contextual search
Contextual search is mostly useful for versioned Docusaurus sites.
Let's consider you have 2 docs versions, v1 and v2. When you are browsing v2 docs, it would be odd to return search results for the v1 documentation. Sometimes v1 and v2 docs are quite similar, and you would end up with duplicate search results for the same query (one result per version).
To solve this problem, the contextual search feature understands that you are browsing a specific docs version, and will create the search query filters dynamically.
- browsing `/docs/v1/myDoc`, search results will only include **v1** docs (+ other unversioned pages)
- browsing `/docs/v2/myDoc`, search results will only include **v2** docs (+ other unversioned pages)
```jsx title="docusaurus.config.js"
module.exports = {
// ...
themeConfig: {
// ...
// highlight-start
algolia: {
contextualSearch: true,
},
// highlight-end
},
};
```
:::caution
If you decide to use contextual search, you can't provide `algolia.searchParameters.facetFilters`, as we compute this `facetFilters` attribute for you dynamically.
:::
### Styling your Algolia search
By default, DocSearch comes with a fine-tuned theme that was designed for accessibility, making sure that colors and contrasts respect standards.

View file

@ -249,8 +249,9 @@ module.exports = {
algolia: {
apiKey: '47ecd3b21be71c5822571b9f59e52544',
indexName: 'docusaurus-2',
// contextualSearch: true,
searchParameters: {
facetFilters: [`version:latest`],
facetFilters: [`version:current`],
},
},
navbar: {