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",
"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"

View file

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

View file

@ -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"
},

View file

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

View file

@ -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',

View file

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

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;
searchParameters: {[key: string]: unknown};
searchPagePath: string | false | null;
replaceSearchResultPathname?: {
from: string;
to: string;
};
};
};
export type UserThemeConfig = DeepPartial<ThemeConfig>;
}
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' {

View file

@ -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<HTMLDivElement | null>(null);
const searchButtonRef = useRef<HTMLButtonElement>(null);
@ -172,20 +174,10 @@ function DocSearch({
const transformItems = useRef<DocSearchModalProps['transformItems']>(
(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'] =

View file

@ -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)}...`
: '',

View file

@ -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<ThemeConfig>({
.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()

View file

@ -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",

View file

@ -103,3 +103,4 @@ export {
findFolderContainingFile,
getFolderContainingFile,
} 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.
*/
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,
};
}

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.
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: {},

View file

@ -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,