Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 11 commits into from Dec 22, 2022
3 changes: 0 additions & 3 deletions packages/docusaurus-plugin-content-blog/package.json
Expand Up @@ -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"
Expand Down
Expand Up @@ -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';

Expand Down Expand Up @@ -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)),
);
}
});
Expand Down
1 change: 0 additions & 1 deletion packages/docusaurus-plugin-content-docs/package.json
Expand Up @@ -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"
},
Expand Down
Expand Up @@ -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';

Expand Down Expand Up @@ -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)),
);
}
});
Expand Down
Expand Up @@ -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}) {
Expand Down Expand Up @@ -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',
Expand Down
Expand Up @@ -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],
);
}
Expand Up @@ -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' {
Expand Down
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -88,6 +90,7 @@ function DocSearch({
...props
}: DocSearchProps) {
const {siteMetadata} = useDocusaurusContext();
const processSearchResultUrl = useSearchResultUrlProcessor();

const contextualSearchFacetFilters =
useAlgoliaContextualFacetFilters() as FacetFilters;
Expand All @@ -107,7 +110,6 @@ function DocSearch({
facetFilters,
};

const {withBaseUrl} = useBaseUrlUtils();
const history = useHistory();
const searchContainer = useRef<HTMLDivElement | null>(null);
const searchButtonRef = useRef<HTMLButtonElement>(null);
Expand Down Expand Up @@ -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'] =
Expand Down
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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)}...`
: '',
Expand Down