diff --git a/packages/docusaurus-plugin-content-blog/package.json b/packages/docusaurus-plugin-content-blog/package.json index adb9ab6d01..5347d4d707 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 7d769afe3f..c6470dae50 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 c5a43d6537..f0c7122d64 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 55da1682c7..0dc44555f2 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 9fc315e1c3..7559aef403 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 a2b338bf27..5050ce6aa0 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 0000000000..80fe16e34e --- /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 0000000000..0414d19f2a --- /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 9cf09fed4d..a6ce183b5d 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 a63d6bc2cb..5e7db3fc6e 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 362c001837..c85953a62e 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 a2a2ccd786..2e1061d864 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 09f1a4aa75..fe0251b615 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 06f553c43f..347e038449 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 0000000000..7240188ced --- /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 c5481e5767..0ba33b8c24 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 e00e022cfa..af520241a4 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 a892ae2602..58ccf39005 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,