mirror of
https://github.com/facebook/docusaurus.git
synced 2025-04-29 18:27:56 +02:00
feat(theme-algolia): add option.replaceSearchResultPathname to process/replaceAll search result urls
#8428
This commit is contained in:
parent
e5b0707fab
commit
deb376e4a6
18 changed files with 214 additions and 51 deletions
|
@ -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"
|
||||
|
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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],
|
||||
);
|
||||
}
|
|
@ -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' {
|
||||
|
|
|
@ -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'] =
|
||||
|
|
|
@ -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)}...`
|
||||
: '',
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -103,3 +103,4 @@ export {
|
|||
findFolderContainingFile,
|
||||
getFolderContainingFile,
|
||||
} from './dataFileUtils';
|
||||
export {escapeRegexp} from './regExpUtils';
|
||||
|
|
12
packages/docusaurus-utils/src/regExpUtils.ts
Normal file
12
packages/docusaurus-utils/src/regExpUtils.ts
Normal 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);
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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: {},
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Reference in a new issue