feat(theme-algolia): add option.replaceSearchResultPathname to process/replaceAll search result urls

#8428
This commit is contained in:
sebastienlorber 2023-01-26 16:36:26 +01:00
parent e5b0707fab
commit deb376e4a6
18 changed files with 214 additions and 51 deletions

View file

@ -35,9 +35,6 @@
"utility-types": "^3.10.0", "utility-types": "^3.10.0",
"webpack": "^5.73.0" "webpack": "^5.73.0"
}, },
"devDependencies": {
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": { "peerDependencies": {
"react": "^16.8.4 || ^17.0.0", "react": "^16.8.4 || ^17.0.0",
"react-dom": "^16.8.4 || ^17.0.0" "react-dom": "^16.8.4 || ^17.0.0"

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree. * 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 {validateBlogPostFrontMatter} from '../frontMatter';
import type {BlogPostFrontMatter} from '@docusaurus/plugin-content-blog'; import type {BlogPostFrontMatter} from '@docusaurus/plugin-content-blog';
@ -57,7 +57,7 @@ function testField(params: {
} catch (err) { } catch (err) {
// eslint-disable-next-line jest/no-conditional-expect // eslint-disable-next-line jest/no-conditional-expect
expect((err as Error).message).toMatch( expect((err as Error).message).toMatch(
new RegExp(escapeStringRegexp(message)), new RegExp(escapeRegexp(message)),
); );
} }
}); });

View file

@ -56,7 +56,6 @@
"@types/js-yaml": "^4.0.5", "@types/js-yaml": "^4.0.5",
"@types/picomatch": "^2.3.0", "@types/picomatch": "^2.3.0",
"commander": "^5.1.0", "commander": "^5.1.0",
"escape-string-regexp": "^4.0.0",
"picomatch": "^2.3.1", "picomatch": "^2.3.1",
"shelljs": "^0.8.5" "shelljs": "^0.8.5"
}, },

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree. * 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 {validateDocFrontMatter} from '../frontMatter';
import type {DocFrontMatter} from '@docusaurus/plugin-content-docs'; import type {DocFrontMatter} from '@docusaurus/plugin-content-docs';
@ -57,7 +57,7 @@ function testField(params: {
} catch (err) { } catch (err) {
// eslint-disable-next-line jest/no-conditional-expect // eslint-disable-next-line jest/no-conditional-expect
expect((err as Error).message).toMatch( expect((err as Error).message).toMatch(
new RegExp(escapeStringRegexp(message)), new RegExp(escapeRegexp(message)),
); );
} }
}); });

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree. * 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'; import type {Joi} from '@docusaurus/utils-validation';
function testValidateThemeConfig(themeConfig: {[key: string]: unknown}) { 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', () => { it('searchParameters.facetFilters search config', () => {
const algolia = { const algolia = {
appId: 'BH4D9OD16A', appId: 'BH4D9OD16A',

View file

@ -5,4 +5,6 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
export {useAlgoliaThemeConfig} from './useAlgoliaThemeConfig';
export {useAlgoliaContextualFacetFilters} from './useAlgoliaContextualFacetFilters'; export {useAlgoliaContextualFacetFilters} from './useAlgoliaContextualFacetFilters';
export {useSearchResultUrlProcessor} from './useSearchResultUrlProcessor';

View file

@ -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;
}

View file

@ -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],
);
}

View file

@ -17,13 +17,23 @@ declare module '@docusaurus/theme-search-algolia' {
indexName: string; indexName: string;
searchParameters: {[key: string]: unknown}; searchParameters: {[key: string]: unknown};
searchPagePath: string | false | null; searchPagePath: string | false | null;
replaceSearchResultPathname?: {
from: string;
to: string;
};
}; };
}; };
export type UserThemeConfig = DeepPartial<ThemeConfig>; export type UserThemeConfig = DeepPartial<ThemeConfig>;
} }
declare module '@docusaurus/theme-search-algolia/client' { 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 useAlgoliaContextualFacetFilters(): [string, string[]];
export function useSearchResultUrlProcessor(): (url: string) => string;
} }
declare module '@theme/SearchPage' { declare module '@theme/SearchPage' {

View file

@ -5,20 +5,23 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import React, {useState, useRef, useCallback, useMemo} from 'react'; import React, {useCallback, useMemo, useRef, useState} from 'react';
import {createPortal} from 'react-dom'; import {DocSearchButton, useDocSearchKeyboardEvents} from '@docsearch/react';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import {useHistory} from '@docusaurus/router';
import {useBaseUrlUtils} from '@docusaurus/useBaseUrl';
import Link from '@docusaurus/Link';
import Head from '@docusaurus/Head'; import Head from '@docusaurus/Head';
import Link from '@docusaurus/Link';
import {useHistory} from '@docusaurus/router';
import {isRegexpStringMatch} from '@docusaurus/theme-common'; import {isRegexpStringMatch} from '@docusaurus/theme-common';
import {useSearchPage} from '@docusaurus/theme-common/internal'; import {useSearchPage} from '@docusaurus/theme-common/internal';
import {DocSearchButton, useDocSearchKeyboardEvents} from '@docsearch/react'; import {
import {useAlgoliaContextualFacetFilters} from '@docusaurus/theme-search-algolia/client'; useAlgoliaContextualFacetFilters,
useSearchResultUrlProcessor,
} from '@docusaurus/theme-search-algolia/client';
import Translate from '@docusaurus/Translate'; import Translate from '@docusaurus/Translate';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import {createPortal} from 'react-dom';
import translations from '@theme/SearchTranslations'; import translations from '@theme/SearchTranslations';
import type {AutocompleteState} from '@algolia/autocomplete-core';
import type { import type {
DocSearchModal as DocSearchModalType, DocSearchModal as DocSearchModalType,
DocSearchModalProps, DocSearchModalProps,
@ -28,7 +31,6 @@ import type {
StoredDocSearchHit, StoredDocSearchHit,
} from '@docsearch/react/dist/esm/types'; } from '@docsearch/react/dist/esm/types';
import type {SearchClient} from 'algoliasearch/lite'; import type {SearchClient} from 'algoliasearch/lite';
import type {AutocompleteState} from '@algolia/autocomplete-core';
type DocSearchProps = Omit< type DocSearchProps = Omit<
DocSearchModalProps, DocSearchModalProps,
@ -88,6 +90,7 @@ function DocSearch({
...props ...props
}: DocSearchProps) { }: DocSearchProps) {
const {siteMetadata} = useDocusaurusContext(); const {siteMetadata} = useDocusaurusContext();
const processSearchResultUrl = useSearchResultUrlProcessor();
const contextualSearchFacetFilters = const contextualSearchFacetFilters =
useAlgoliaContextualFacetFilters() as FacetFilters; useAlgoliaContextualFacetFilters() as FacetFilters;
@ -107,7 +110,6 @@ function DocSearch({
facetFilters, facetFilters,
}; };
const {withBaseUrl} = useBaseUrlUtils();
const history = useHistory(); const history = useHistory();
const searchContainer = useRef<HTMLDivElement | null>(null); const searchContainer = useRef<HTMLDivElement | null>(null);
const searchButtonRef = useRef<HTMLButtonElement>(null); const searchButtonRef = useRef<HTMLButtonElement>(null);
@ -172,20 +174,10 @@ function DocSearch({
const transformItems = useRef<DocSearchModalProps['transformItems']>( const transformItems = useRef<DocSearchModalProps['transformItems']>(
(items) => (items) =>
items.map((item) => { items.map((item) => ({
// If Algolia contains a external domain, we should navigate without ...item,
// relative URL url: processSearchResultUrl(item.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}`),
};
}),
).current; ).current;
const resultsFooterComponent: DocSearchProps['resultsFooterComponent'] = const resultsFooterComponent: DocSearchProps['resultsFooterComponent'] =

View file

@ -7,32 +7,34 @@
/* eslint-disable jsx-a11y/no-autofocus */ /* 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 clsx from 'clsx';
import algoliaSearch from 'algoliasearch/lite';
import algoliaSearchHelper from 'algoliasearch-helper'; import algoliaSearchHelper from 'algoliasearch-helper';
import algoliaSearch from 'algoliasearch/lite';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import Head from '@docusaurus/Head'; import Head from '@docusaurus/Head';
import Link from '@docusaurus/Link'; import Link from '@docusaurus/Link';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; import {useAllDocsData} from '@docusaurus/plugin-content-docs/client';
import { import {
HtmlClassNameProvider, HtmlClassNameProvider,
usePluralForm,
isRegexpStringMatch,
useEvent, useEvent,
usePluralForm,
} from '@docusaurus/theme-common'; } from '@docusaurus/theme-common';
import { import {
useTitleFormatter,
useSearchPage, useSearchPage,
useTitleFormatter,
} from '@docusaurus/theme-common/internal'; } 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 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 Layout from '@theme/Layout';
import styles from './styles.module.css'; import styles from './styles.module.css';
import type {ThemeConfig} from '@docusaurus/theme-search-algolia';
// Very simple pluralization: probably good enough for now // Very simple pluralization: probably good enough for now
function useDocumentsFoundPlural() { function useDocumentsFoundPlural() {
@ -156,12 +158,12 @@ type ResultDispatcher =
function SearchPageContent(): JSX.Element { function SearchPageContent(): JSX.Element {
const { const {
siteConfig: {themeConfig},
i18n: {currentLocale}, i18n: {currentLocale},
} = useDocusaurusContext(); } = useDocusaurusContext();
const { const {
algolia: {appId, apiKey, indexName, externalUrlRegex}, algolia: {appId, apiKey, indexName},
} = themeConfig as ThemeConfig; } = useAlgoliaThemeConfig();
const processSearchResultUrl = useSearchResultUrlProcessor();
const documentsFoundPlural = useDocumentsFoundPlural(); const documentsFoundPlural = useDocumentsFoundPlural();
const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers(); const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers();
@ -244,16 +246,12 @@ function SearchPageContent(): JSX.Element {
_highlightResult: {hierarchy: {[key: string]: {value: string}}}; _highlightResult: {hierarchy: {[key: string]: {value: string}}};
_snippetResult: {content?: {value: string}}; _snippetResult: {content?: {value: string}};
}) => { }) => {
const parsedURL = new URL(url);
const titles = Object.keys(hierarchy).map((key) => const titles = Object.keys(hierarchy).map((key) =>
sanitizeValue(hierarchy[key]!.value), sanitizeValue(hierarchy[key]!.value),
); );
return { return {
title: titles.pop()!, title: titles.pop()!,
url: isRegexpStringMatch(externalUrlRegex, parsedURL.href) url: processSearchResultUrl(url),
? parsedURL.href
: parsedURL.pathname + parsedURL.hash,
summary: snippet.content summary: snippet.content
? `${sanitizeValue(snippet.content.value)}...` ? `${sanitizeValue(snippet.content.value)}...`
: '', : '',

View file

@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import {escapeRegexp} from '@docusaurus/utils';
import {Joi} from '@docusaurus/utils-validation'; import {Joi} from '@docusaurus/utils-validation';
import type { import type {
ThemeConfig, ThemeConfig,
@ -39,6 +40,19 @@ export const Schema = Joi.object<ThemeConfig>({
.try(Joi.boolean().invalid(true), Joi.string()) .try(Joi.boolean().invalid(true), Joi.string())
.allow(null) .allow(null)
.default(DEFAULT_CONFIG.searchPagePath), .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') .label('themeConfig.algolia')
.required() .required()

View file

@ -20,6 +20,7 @@
"dependencies": { "dependencies": {
"@docusaurus/logger": "2.2.0", "@docusaurus/logger": "2.2.0",
"@svgr/webpack": "^6.2.1", "@svgr/webpack": "^6.2.1",
"escape-string-regexp": "^4.0.0",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"fs-extra": "^10.1.0", "fs-extra": "^10.1.0",
"github-slugger": "^1.4.0", "github-slugger": "^1.4.0",

View file

@ -103,3 +103,4 @@ export {
findFolderContainingFile, findFolderContainingFile,
getFolderContainingFile, getFolderContainingFile,
} from './dataFileUtils'; } from './dataFileUtils';
export {escapeRegexp} from './regExpUtils';

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.
*/
import escapeStringRegexp from 'escape-string-regexp';
export function escapeRegexp(string: string): string {
return escapeStringRegexp(string);
}

View file

@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import {useCallback} from 'react';
import useDocusaurusContext from './useDocusaurusContext'; import useDocusaurusContext from './useDocusaurusContext';
import {hasProtocol} from './isInternalUrl'; import {hasProtocol} from './isInternalUrl';
import type {BaseUrlOptions, BaseUrlUtils} from '@docusaurus/useBaseUrl'; import type {BaseUrlOptions, BaseUrlUtils} from '@docusaurus/useBaseUrl';
@ -43,8 +44,15 @@ export function useBaseUrlUtils(): BaseUrlUtils {
const { const {
siteConfig: {baseUrl, url: siteUrl}, siteConfig: {baseUrl, url: siteUrl},
} = useDocusaurusContext(); } = useDocusaurusContext();
const withBaseUrl = useCallback(
(url: string, options?: BaseUrlOptions) =>
addBaseUrl(siteUrl, baseUrl, url, options),
[siteUrl, baseUrl],
);
return { return {
withBaseUrl: (url, options) => addBaseUrl(siteUrl, baseUrl, url, options), withBaseUrl,
}; };
} }

View file

@ -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. // 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', 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 // Optional: Algolia search parameters
searchParameters: {}, searchParameters: {},

View file

@ -401,6 +401,13 @@ const config = {
appId: 'X1Z85QJPUV', appId: 'X1Z85QJPUV',
apiKey: 'bf7211c161e8205da2f933a02534105a', apiKey: 'bf7211c161e8205da2f933a02534105a',
indexName: 'docusaurus-2', indexName: 'docusaurus-2',
replaceSearchResultPathname:
isDev || isDeployPreview
? {
from: /^\/docs\/next/g,
to: '/docs',
}
: undefined,
}, },
navbar: { navbar: {
hideOnScroll: true, hideOnScroll: true,