From deb376e4a6b60f849dfeeca3f82d58955b11df4a Mon Sep 17 00:00:00 2001 From: sebastienlorber Date: Thu, 26 Jan 2023 16:36:26 +0100 Subject: [PATCH] feat(theme-algolia): add option.replaceSearchResultPathname to process/replaceAll search result urls #8428 --- .../package.json | 3 -- .../src/__tests__/frontMatter.test.ts | 4 +- .../package.json | 1 - .../src/__tests__/frontMatter.test.ts | 4 +- .../src/__tests__/validateThemeConfig.test.ts | 49 ++++++++++++++++- .../src/client/index.ts | 2 + .../src/client/useAlgoliaThemeConfig.ts | 15 ++++++ .../src/client/useSearchResultUrlProcessor.ts | 54 +++++++++++++++++++ .../src/theme-search-algolia.d.ts | 10 ++++ .../src/theme/SearchBar/index.tsx | 40 ++++++-------- .../src/theme/SearchPage/index.tsx | 32 ++++++----- .../src/validateThemeConfig.ts | 14 +++++ packages/docusaurus-utils/package.json | 1 + packages/docusaurus-utils/src/index.ts | 1 + packages/docusaurus-utils/src/regExpUtils.ts | 12 +++++ .../src/client/exports/useBaseUrl.ts | 10 +++- website/docs/search.md | 6 +++ website/docusaurus.config.js | 7 +++ 18 files changed, 214 insertions(+), 51 deletions(-) create mode 100644 packages/docusaurus-theme-search-algolia/src/client/useAlgoliaThemeConfig.ts create mode 100644 packages/docusaurus-theme-search-algolia/src/client/useSearchResultUrlProcessor.ts create mode 100644 packages/docusaurus-utils/src/regExpUtils.ts diff --git a/packages/docusaurus-plugin-content-blog/package.json b/packages/docusaurus-plugin-content-blog/package.json index adb9ab6d012b..5347d4d707e8 100644 --- a/packages/docusaurus-plugin-content-blog/package.json +++ b/packages/docusaurus-plugin-content-blog/package.json @@ -35,9 +35,6 @@ "utility-types": "^3.10.0", "webpack": "^5.73.0" }, - "devDependencies": { - "escape-string-regexp": "^4.0.0" - }, "peerDependencies": { "react": "^16.8.4 || ^17.0.0", "react-dom": "^16.8.4 || ^17.0.0" diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/frontMatter.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/frontMatter.test.ts index 7d769afe3f69..c6470dae506a 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/frontMatter.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/frontMatter.test.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import escapeStringRegexp from 'escape-string-regexp'; +import {escapeRegexp} from '@docusaurus/utils'; import {validateBlogPostFrontMatter} from '../frontMatter'; import type {BlogPostFrontMatter} from '@docusaurus/plugin-content-blog'; @@ -57,7 +57,7 @@ function testField(params: { } catch (err) { // eslint-disable-next-line jest/no-conditional-expect expect((err as Error).message).toMatch( - new RegExp(escapeStringRegexp(message)), + new RegExp(escapeRegexp(message)), ); } }); diff --git a/packages/docusaurus-plugin-content-docs/package.json b/packages/docusaurus-plugin-content-docs/package.json index c5a43d6537bd..f0c7122d6408 100644 --- a/packages/docusaurus-plugin-content-docs/package.json +++ b/packages/docusaurus-plugin-content-docs/package.json @@ -56,7 +56,6 @@ "@types/js-yaml": "^4.0.5", "@types/picomatch": "^2.3.0", "commander": "^5.1.0", - "escape-string-regexp": "^4.0.0", "picomatch": "^2.3.1", "shelljs": "^0.8.5" }, diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/frontMatter.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/frontMatter.test.ts index 55da1682c725..0dc44555f24f 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/frontMatter.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/frontMatter.test.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import escapeStringRegexp from 'escape-string-regexp'; +import {escapeRegexp} from '@docusaurus/utils'; import {validateDocFrontMatter} from '../frontMatter'; import type {DocFrontMatter} from '@docusaurus/plugin-content-docs'; @@ -57,7 +57,7 @@ function testField(params: { } catch (err) { // eslint-disable-next-line jest/no-conditional-expect expect((err as Error).message).toMatch( - new RegExp(escapeStringRegexp(message)), + new RegExp(escapeRegexp(message)), ); } }); diff --git a/packages/docusaurus-theme-search-algolia/src/__tests__/validateThemeConfig.test.ts b/packages/docusaurus-theme-search-algolia/src/__tests__/validateThemeConfig.test.ts index 9fc315e1c3ba..7559aef403ba 100644 --- a/packages/docusaurus-theme-search-algolia/src/__tests__/validateThemeConfig.test.ts +++ b/packages/docusaurus-theme-search-algolia/src/__tests__/validateThemeConfig.test.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import {validateThemeConfig, DEFAULT_CONFIG} from '../validateThemeConfig'; +import {DEFAULT_CONFIG, validateThemeConfig} from '../validateThemeConfig'; import type {Joi} from '@docusaurus/utils-validation'; function testValidateThemeConfig(themeConfig: {[key: string]: unknown}) { @@ -121,6 +121,53 @@ describe('validateThemeConfig', () => { }); }); + describe('replaceSearchResultPathname', () => { + it('escapes from string', () => { + const algolia = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + replaceSearchResultPathname: { + from: '/docs/some-\\special-.[regexp]{chars*}', + to: '/abc', + }, + }; + expect(testValidateThemeConfig({algolia})).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + replaceSearchResultPathname: { + from: '/docs/some\\x2d\\\\special\\x2d\\.\\[regexp\\]\\{chars\\*\\}', + to: '/abc', + }, + }, + }); + }); + + it('converts from regexp to string', () => { + const algolia = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + replaceSearchResultPathname: { + from: /^\/docs\/(?:1\.0|next)/, + to: '/abc', + }, + }; + + expect(testValidateThemeConfig({algolia})).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + replaceSearchResultPathname: { + from: '^\\/docs\\/(?:1\\.0|next)', + to: '/abc', + }, + }, + }); + }); + }); + it('searchParameters.facetFilters search config', () => { const algolia = { appId: 'BH4D9OD16A', diff --git a/packages/docusaurus-theme-search-algolia/src/client/index.ts b/packages/docusaurus-theme-search-algolia/src/client/index.ts index a2b338bf27f4..5050ce6aa057 100644 --- a/packages/docusaurus-theme-search-algolia/src/client/index.ts +++ b/packages/docusaurus-theme-search-algolia/src/client/index.ts @@ -5,4 +5,6 @@ * LICENSE file in the root directory of this source tree. */ +export {useAlgoliaThemeConfig} from './useAlgoliaThemeConfig'; export {useAlgoliaContextualFacetFilters} from './useAlgoliaContextualFacetFilters'; +export {useSearchResultUrlProcessor} from './useSearchResultUrlProcessor'; diff --git a/packages/docusaurus-theme-search-algolia/src/client/useAlgoliaThemeConfig.ts b/packages/docusaurus-theme-search-algolia/src/client/useAlgoliaThemeConfig.ts new file mode 100644 index 000000000000..80fe16e34ecb --- /dev/null +++ b/packages/docusaurus-theme-search-algolia/src/client/useAlgoliaThemeConfig.ts @@ -0,0 +1,15 @@ +/** + * 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 useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import type {ThemeConfig} from '@docusaurus/theme-search-algolia'; + +export function useAlgoliaThemeConfig(): ThemeConfig { + const { + siteConfig: {themeConfig}, + } = useDocusaurusContext(); + return themeConfig as ThemeConfig; +} diff --git a/packages/docusaurus-theme-search-algolia/src/client/useSearchResultUrlProcessor.ts b/packages/docusaurus-theme-search-algolia/src/client/useSearchResultUrlProcessor.ts new file mode 100644 index 000000000000..0414d19f2a42 --- /dev/null +++ b/packages/docusaurus-theme-search-algolia/src/client/useSearchResultUrlProcessor.ts @@ -0,0 +1,54 @@ +/** + * 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 {useCallback} from 'react'; +import {isRegexpStringMatch} from '@docusaurus/theme-common'; +import {useBaseUrlUtils} from '@docusaurus/useBaseUrl'; +import {useAlgoliaThemeConfig} from './useAlgoliaThemeConfig'; +import type {ThemeConfig} from '@docusaurus/theme-search-algolia'; + +function replacePathname( + pathname: string, + replaceSearchResultPathname: ThemeConfig['algolia']['replaceSearchResultPathname'], +): string { + return replaceSearchResultPathname + ? pathname.replaceAll( + new RegExp(replaceSearchResultPathname.from, 'g'), + replaceSearchResultPathname.to, + ) + : pathname; +} + +/** + * Process the search result url from Algolia to its final form, ready to be + * navigated to or used as a link + */ +export function useSearchResultUrlProcessor(): (url: string) => string { + const {withBaseUrl} = useBaseUrlUtils(); + const { + algolia: {externalUrlRegex, replaceSearchResultPathname}, + } = useAlgoliaThemeConfig(); + + return useCallback( + (url: string) => { + const parsedURL = new URL(url); + + // Algolia contains an external domain => navigate to URL + if (isRegexpStringMatch(externalUrlRegex, parsedURL.href)) { + return url; + } + + // Otherwise => transform to relative URL for SPA navigation + const relativeUrl = `${parsedURL.pathname + parsedURL.hash}`; + + return withBaseUrl( + replacePathname(relativeUrl, replaceSearchResultPathname), + ); + }, + [withBaseUrl, externalUrlRegex, replaceSearchResultPathname], + ); +} diff --git a/packages/docusaurus-theme-search-algolia/src/theme-search-algolia.d.ts b/packages/docusaurus-theme-search-algolia/src/theme-search-algolia.d.ts index 9cf09fed4d05..a6ce183b5dd7 100644 --- a/packages/docusaurus-theme-search-algolia/src/theme-search-algolia.d.ts +++ b/packages/docusaurus-theme-search-algolia/src/theme-search-algolia.d.ts @@ -17,13 +17,23 @@ declare module '@docusaurus/theme-search-algolia' { indexName: string; searchParameters: {[key: string]: unknown}; searchPagePath: string | false | null; + replaceSearchResultPathname?: { + from: string; + to: string; + }; }; }; export type UserThemeConfig = DeepPartial; } declare module '@docusaurus/theme-search-algolia/client' { + import type {ThemeConfig} from '@docusaurus/theme-search-algolia'; + + export function useAlgoliaThemeConfig(): ThemeConfig; + export function useAlgoliaContextualFacetFilters(): [string, string[]]; + + export function useSearchResultUrlProcessor(): (url: string) => string; } declare module '@theme/SearchPage' { diff --git a/packages/docusaurus-theme-search-algolia/src/theme/SearchBar/index.tsx b/packages/docusaurus-theme-search-algolia/src/theme/SearchBar/index.tsx index a63d6bc2cbf0..5e7db3fc6ec2 100644 --- a/packages/docusaurus-theme-search-algolia/src/theme/SearchBar/index.tsx +++ b/packages/docusaurus-theme-search-algolia/src/theme/SearchBar/index.tsx @@ -5,20 +5,23 @@ * LICENSE file in the root directory of this source tree. */ -import React, {useState, useRef, useCallback, useMemo} from 'react'; -import {createPortal} from 'react-dom'; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; -import {useHistory} from '@docusaurus/router'; -import {useBaseUrlUtils} from '@docusaurus/useBaseUrl'; -import Link from '@docusaurus/Link'; +import React, {useCallback, useMemo, useRef, useState} from 'react'; +import {DocSearchButton, useDocSearchKeyboardEvents} from '@docsearch/react'; import Head from '@docusaurus/Head'; +import Link from '@docusaurus/Link'; +import {useHistory} from '@docusaurus/router'; import {isRegexpStringMatch} from '@docusaurus/theme-common'; import {useSearchPage} from '@docusaurus/theme-common/internal'; -import {DocSearchButton, useDocSearchKeyboardEvents} from '@docsearch/react'; -import {useAlgoliaContextualFacetFilters} from '@docusaurus/theme-search-algolia/client'; +import { + useAlgoliaContextualFacetFilters, + useSearchResultUrlProcessor, +} from '@docusaurus/theme-search-algolia/client'; import Translate from '@docusaurus/Translate'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import {createPortal} from 'react-dom'; import translations from '@theme/SearchTranslations'; +import type {AutocompleteState} from '@algolia/autocomplete-core'; import type { DocSearchModal as DocSearchModalType, DocSearchModalProps, @@ -28,7 +31,6 @@ import type { StoredDocSearchHit, } from '@docsearch/react/dist/esm/types'; import type {SearchClient} from 'algoliasearch/lite'; -import type {AutocompleteState} from '@algolia/autocomplete-core'; type DocSearchProps = Omit< DocSearchModalProps, @@ -88,6 +90,7 @@ function DocSearch({ ...props }: DocSearchProps) { const {siteMetadata} = useDocusaurusContext(); + const processSearchResultUrl = useSearchResultUrlProcessor(); const contextualSearchFacetFilters = useAlgoliaContextualFacetFilters() as FacetFilters; @@ -107,7 +110,6 @@ function DocSearch({ facetFilters, }; - const {withBaseUrl} = useBaseUrlUtils(); const history = useHistory(); const searchContainer = useRef(null); const searchButtonRef = useRef(null); @@ -172,20 +174,10 @@ function DocSearch({ const transformItems = useRef( (items) => - items.map((item) => { - // If Algolia contains a external domain, we should navigate without - // relative URL - if (isRegexpStringMatch(externalUrlRegex, item.url)) { - return item; - } - - // We transform the absolute URL into a relative URL. - const url = new URL(item.url); - return { - ...item, - url: withBaseUrl(`${url.pathname}${url.hash}`), - }; - }), + items.map((item) => ({ + ...item, + url: processSearchResultUrl(item.url), + })), ).current; const resultsFooterComponent: DocSearchProps['resultsFooterComponent'] = diff --git a/packages/docusaurus-theme-search-algolia/src/theme/SearchPage/index.tsx b/packages/docusaurus-theme-search-algolia/src/theme/SearchPage/index.tsx index 362c001837d3..c85953a62e40 100644 --- a/packages/docusaurus-theme-search-algolia/src/theme/SearchPage/index.tsx +++ b/packages/docusaurus-theme-search-algolia/src/theme/SearchPage/index.tsx @@ -7,32 +7,34 @@ /* eslint-disable jsx-a11y/no-autofocus */ -import React, {useEffect, useState, useReducer, useRef} from 'react'; +import React, {useEffect, useReducer, useRef, useState} from 'react'; import clsx from 'clsx'; -import algoliaSearch from 'algoliasearch/lite'; 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 ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; +import {useAllDocsData} from '@docusaurus/plugin-content-docs/client'; import { HtmlClassNameProvider, - usePluralForm, - isRegexpStringMatch, useEvent, + usePluralForm, } from '@docusaurus/theme-common'; import { - useTitleFormatter, useSearchPage, + useTitleFormatter, } 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 useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { + useAlgoliaThemeConfig, + useSearchResultUrlProcessor, +} from '@docusaurus/theme-search-algolia/client'; import Layout from '@theme/Layout'; import styles from './styles.module.css'; -import type {ThemeConfig} from '@docusaurus/theme-search-algolia'; // Very simple pluralization: probably good enough for now function useDocumentsFoundPlural() { @@ -156,12 +158,12 @@ type ResultDispatcher = function SearchPageContent(): JSX.Element { const { - siteConfig: {themeConfig}, i18n: {currentLocale}, } = useDocusaurusContext(); const { - algolia: {appId, apiKey, indexName, externalUrlRegex}, - } = themeConfig as ThemeConfig; + algolia: {appId, apiKey, indexName}, + } = useAlgoliaThemeConfig(); + const processSearchResultUrl = useSearchResultUrlProcessor(); const documentsFoundPlural = useDocumentsFoundPlural(); const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers(); @@ -244,16 +246,12 @@ function SearchPageContent(): JSX.Element { _highlightResult: {hierarchy: {[key: string]: {value: string}}}; _snippetResult: {content?: {value: string}}; }) => { - const parsedURL = new URL(url); const titles = Object.keys(hierarchy).map((key) => sanitizeValue(hierarchy[key]!.value), ); - return { title: titles.pop()!, - url: isRegexpStringMatch(externalUrlRegex, parsedURL.href) - ? parsedURL.href - : parsedURL.pathname + parsedURL.hash, + url: processSearchResultUrl(url), summary: snippet.content ? `${sanitizeValue(snippet.content.value)}...` : '', diff --git a/packages/docusaurus-theme-search-algolia/src/validateThemeConfig.ts b/packages/docusaurus-theme-search-algolia/src/validateThemeConfig.ts index a2a2ccd7869e..2e1061d86429 100644 --- a/packages/docusaurus-theme-search-algolia/src/validateThemeConfig.ts +++ b/packages/docusaurus-theme-search-algolia/src/validateThemeConfig.ts @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. */ +import {escapeRegexp} from '@docusaurus/utils'; import {Joi} from '@docusaurus/utils-validation'; import type { ThemeConfig, @@ -39,6 +40,19 @@ export const Schema = Joi.object({ .try(Joi.boolean().invalid(true), Joi.string()) .allow(null) .default(DEFAULT_CONFIG.searchPagePath), + replaceSearchResultPathname: Joi.object({ + from: Joi.custom((from) => { + if (typeof from === 'string') { + return escapeRegexp(from); + } else if (from instanceof RegExp) { + return from.source; + } + throw new Error( + `it should be a RegExp or a string, but received ${from}`, + ); + }).required(), + to: Joi.string().required(), + }).optional(), }) .label('themeConfig.algolia') .required() diff --git a/packages/docusaurus-utils/package.json b/packages/docusaurus-utils/package.json index 09f1a4aa75b7..fe0251b615bc 100644 --- a/packages/docusaurus-utils/package.json +++ b/packages/docusaurus-utils/package.json @@ -20,6 +20,7 @@ "dependencies": { "@docusaurus/logger": "2.2.0", "@svgr/webpack": "^6.2.1", + "escape-string-regexp": "^4.0.0", "file-loader": "^6.2.0", "fs-extra": "^10.1.0", "github-slugger": "^1.4.0", diff --git a/packages/docusaurus-utils/src/index.ts b/packages/docusaurus-utils/src/index.ts index 06f553c43f1c..347e03844964 100644 --- a/packages/docusaurus-utils/src/index.ts +++ b/packages/docusaurus-utils/src/index.ts @@ -103,3 +103,4 @@ export { findFolderContainingFile, getFolderContainingFile, } from './dataFileUtils'; +export {escapeRegexp} from './regExpUtils'; diff --git a/packages/docusaurus-utils/src/regExpUtils.ts b/packages/docusaurus-utils/src/regExpUtils.ts new file mode 100644 index 000000000000..7240188ced96 --- /dev/null +++ b/packages/docusaurus-utils/src/regExpUtils.ts @@ -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. + */ + +import escapeStringRegexp from 'escape-string-regexp'; + +export function escapeRegexp(string: string): string { + return escapeStringRegexp(string); +} diff --git a/packages/docusaurus/src/client/exports/useBaseUrl.ts b/packages/docusaurus/src/client/exports/useBaseUrl.ts index c5481e57670f..0ba33b8c24ea 100644 --- a/packages/docusaurus/src/client/exports/useBaseUrl.ts +++ b/packages/docusaurus/src/client/exports/useBaseUrl.ts @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. */ +import {useCallback} from 'react'; import useDocusaurusContext from './useDocusaurusContext'; import {hasProtocol} from './isInternalUrl'; import type {BaseUrlOptions, BaseUrlUtils} from '@docusaurus/useBaseUrl'; @@ -43,8 +44,15 @@ export function useBaseUrlUtils(): BaseUrlUtils { const { siteConfig: {baseUrl, url: siteUrl}, } = useDocusaurusContext(); + + const withBaseUrl = useCallback( + (url: string, options?: BaseUrlOptions) => + addBaseUrl(siteUrl, baseUrl, url, options), + [siteUrl, baseUrl], + ); + return { - withBaseUrl: (url, options) => addBaseUrl(siteUrl, baseUrl, url, options), + withBaseUrl, }; } diff --git a/website/docs/search.md b/website/docs/search.md index e00e022cfaca..af520241a446 100644 --- a/website/docs/search.md +++ b/website/docs/search.md @@ -104,6 +104,12 @@ module.exports = { // Optional: Specify domains where the navigation should occur through window.location instead on history.push. Useful when our Algolia config crawls multiple documentation sites and we want to navigate with window.location.href to them. externalUrlRegex: 'external\\.com|domain\\.com', + // Optional: Replace parts of the item URLs from Algolia. Useful when using the same search index for multiple deployments using a different baseUrl. You can use regexp or string in the `from` param. For example: localhost:3000 vs myCompany.com/docs + replaceSearchResultPathname: { + from: '/docs/', // or as RegExp: /\/docs\// + to: '/', + }, + // Optional: Algolia search parameters searchParameters: {}, diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index a892ae260284..58ccf39005cd 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -401,6 +401,13 @@ const config = { appId: 'X1Z85QJPUV', apiKey: 'bf7211c161e8205da2f933a02534105a', indexName: 'docusaurus-2', + replaceSearchResultPathname: + isDev || isDeployPreview + ? { + from: /^\/docs\/next/g, + to: '/docs', + } + : undefined, }, navbar: { hideOnScroll: true,