diff --git a/packages/docusaurus-plugin-content-blog/package.json b/packages/docusaurus-plugin-content-blog/package.json index f19896abbd48..7d788be730ee 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.74.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 3e3580d92c5c..9c05dcca944a 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'; @@ -54,7 +54,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 3dc78f844165..62d25ef7f99b 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 7aeada8201b8..8589a707681b 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'; @@ -54,7 +54,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 1e359c228fb4..83cbf8ec66f7 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,33 @@ /* 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 type {ThemeConfig} from '@docusaurus/theme-search-algolia'; - import styles from './styles.module.css'; // Very simple pluralization: probably good enough for now @@ -157,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(); @@ -245,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 cef5263c8367..5434576aa5dc 100644 --- a/packages/docusaurus-utils/package.json +++ b/packages/docusaurus-utils/package.json @@ -20,6 +20,7 @@ "dependencies": { "@docusaurus/logger": "^3.0.0-alpha.0", "@svgr/webpack": "^6.3.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 ba74f8d3d134..2d9349d48c33 100644 --- a/packages/docusaurus-utils/src/index.ts +++ b/packages/docusaurus-utils/src/index.ts @@ -105,3 +105,4 @@ export { getFolderContainingFile, } from './dataFileUtils'; export {isDraft, isUnlisted} from './contentVisibilityUtils'; +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 31f1084c0f3d..85c7391cc4a1 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 6e00175fb995..870cbff8b812 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -409,6 +409,13 @@ const config = { appId: 'X1Z85QJPUV', apiKey: 'bf7211c161e8205da2f933a02534105a', indexName: 'docusaurus-2', + replaceSearchResultPathname: + isDev || isDeployPreview + ? { + from: /^\/docs\/next/g, + to: '/docs', + } + : undefined, }, navbar: { hideOnScroll: true,