diff --git a/packages/docusaurus-plugin-content-docs/src/index.ts b/packages/docusaurus-plugin-content-docs/src/index.ts
index a362cb866d..30f01730fc 100644
--- a/packages/docusaurus-plugin-content-docs/src/index.ts
+++ b/packages/docusaurus-plugin-content-docs/src/index.ts
@@ -46,6 +46,7 @@ import {
} from './types';
import {Configuration} from 'webpack';
import {docsVersion} from './version';
+import {VERSIONS_JSON_FILE} from './constants';
const DEFAULT_OPTIONS: PluginOptions = {
path: 'docs', // Path to data on filesystem, relative to site dir.
@@ -95,6 +96,10 @@ export default function pluginContentDocs(
return {
name: 'docusaurus-plugin-content-docs',
+ getThemePath() {
+ return path.resolve(__dirname, './theme');
+ },
+
extendCli(cli) {
cli
.command('docs:version')
@@ -418,7 +423,16 @@ export default function pluginContentDocs(
configureWebpack(_config, isServer, utils) {
const {getBabelLoader, getCacheLoader} = utils;
const {rehypePlugins, remarkPlugins} = options;
+ // Suppress warnings about non-existing of versions file.
+ const stats = {
+ warningsFilter: [VERSIONS_JSON_FILE],
+ };
+
return {
+ stats,
+ devServer: {
+ stats,
+ },
resolve: {
alias: {
'~docs': dataDir,
diff --git a/packages/docusaurus-plugin-content-docs/src/theme/hooks/useVersioning.ts b/packages/docusaurus-plugin-content-docs/src/theme/hooks/useVersioning.ts
new file mode 100644
index 0000000000..9bc53606fb
--- /dev/null
+++ b/packages/docusaurus-plugin-content-docs/src/theme/hooks/useVersioning.ts
@@ -0,0 +1,22 @@
+/**
+ * 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.
+ */
+
+let versions: string[] = [];
+
+try {
+ versions = require('@site/versions.json');
+} catch (e) {}
+
+function useVersioning() {
+ return {
+ versioningEnabled: versions.length > 0,
+ versions,
+ latestVersion: versions[0],
+ };
+}
+
+export default useVersioning;
diff --git a/packages/docusaurus-theme-search-algolia/package.json b/packages/docusaurus-theme-search-algolia/package.json
index 60b65f4197..a31fcf5458 100644
--- a/packages/docusaurus-theme-search-algolia/package.json
+++ b/packages/docusaurus-theme-search-algolia/package.json
@@ -8,11 +8,15 @@
},
"license": "MIT",
"dependencies": {
+ "algoliasearch": "^3.24.5",
+ "algoliasearch-helper": "^3.1.1",
"classnames": "^2.2.6",
- "docsearch.js": "^2.6.3"
+ "docsearch.js": "^2.6.3",
+ "eta": "^1.1.1"
},
"peerDependencies": {
"@docusaurus/core": "^2.0.0",
+ "@docusaurus/utils": "^2.0.0-alpha.54",
"react": "^16.8.4",
"react-dom": "^16.8.4"
},
diff --git a/packages/docusaurus-theme-search-algolia/src/index.js b/packages/docusaurus-theme-search-algolia/src/index.js
index 30dcc94cd7..8528e04190 100644
--- a/packages/docusaurus-theme-search-algolia/src/index.js
+++ b/packages/docusaurus-theme-search-algolia/src/index.js
@@ -6,8 +6,20 @@
*/
const path = require('path');
+const fs = require('fs');
+const eta = require('eta');
+const {normalizeUrl} = require('@docusaurus/utils');
+const openSearchTemplate = require('./templates/opensearch');
+
+const OPEN_SEARCH_FILENAME = 'opensearch.xml';
+
+module.exports = function (context) {
+ const {
+ baseUrl,
+ siteConfig: {title, url, favicon},
+ } = context;
+ const pagePath = path.resolve(__dirname, './pages/search/index.js');
-module.exports = function () {
return {
name: 'docusaurus-theme-search-algolia',
@@ -15,6 +27,10 @@ module.exports = function () {
return path.resolve(__dirname, './theme');
},
+ getPathsToWatch() {
+ return [pagePath];
+ },
+
configureWebpack() {
// Ensure that algolia docsearch styles is its own chunk.
return {
@@ -34,5 +50,44 @@ module.exports = function () {
},
};
},
+
+ async contentLoaded({actions: {addRoute}}) {
+ addRoute({
+ path: normalizeUrl([baseUrl, 'search']),
+ component: pagePath,
+ exact: true,
+ });
+ },
+
+ async postBuild({outDir}) {
+ try {
+ fs.writeFileSync(
+ path.join(outDir, OPEN_SEARCH_FILENAME),
+ eta.render(openSearchTemplate.trim(), {
+ title,
+ url,
+ favicon: normalizeUrl([url, favicon]),
+ }),
+ );
+ } catch (err) {
+ throw new Error(`Generating OpenSearch file failed: ${err}`);
+ }
+ },
+
+ injectHtmlTags() {
+ return {
+ headTags: [
+ {
+ tagName: 'link',
+ attributes: {
+ rel: 'search',
+ type: 'application/opensearchdescription+xml',
+ title,
+ href: normalizeUrl([baseUrl, OPEN_SEARCH_FILENAME]),
+ },
+ },
+ ],
+ };
+ },
};
};
diff --git a/packages/docusaurus-theme-search-algolia/src/pages/search/index.js b/packages/docusaurus-theme-search-algolia/src/pages/search/index.js
new file mode 100644
index 0000000000..bb96bcd407
--- /dev/null
+++ b/packages/docusaurus-theme-search-algolia/src/pages/search/index.js
@@ -0,0 +1,354 @@
+/**
+ * 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 React, {useEffect, useState, useReducer, useRef} from 'react';
+
+import algoliaSearch from 'algoliasearch/lite';
+import algoliaSearchHelper from 'algoliasearch-helper';
+import classnames from 'classnames';
+
+import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
+import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
+import useVersioning from '@theme/hooks/useVersioning';
+import useSearchQuery from '@theme/hooks/useSearchQuery';
+import Link from '@docusaurus/Link';
+import Layout from '@theme/Layout';
+
+import styles from './styles.module.css';
+
+function Search() {
+ const {
+ siteConfig: {
+ themeConfig: {algolia: {appId = 'BH4D9OD16A', apiKey, indexName} = {}},
+ } = {},
+ } = useDocusaurusContext();
+ const {searchValue, updateSearchPath} = useSearchQuery();
+ const {versioningEnabled, versions, latestVersion} = useVersioning();
+ const [searchVersion, setSearchVersion] = useState(latestVersion);
+ const [searchQuery, setSearchQuery] = useState(searchValue);
+ const initialSearchResultState = {
+ items: [],
+ query: null,
+ totalResults: null,
+ totalPages: null,
+ lastPage: null,
+ hasMore: null,
+ loading: null,
+ };
+ const [searchResultState, searchResultStateDispatcher] = useReducer(
+ (prevState, {type, value: state}) => {
+ switch (type) {
+ case 'reset': {
+ return initialSearchResultState;
+ }
+ case 'loading': {
+ return {...prevState, loading: true};
+ }
+ case 'update': {
+ if (searchQuery !== state.query) {
+ return prevState;
+ }
+
+ return {
+ ...state,
+ items:
+ state.lastPage === 0
+ ? state.items
+ : prevState.items.concat(state.items),
+ };
+ }
+ case 'advance': {
+ const hasMore = prevState.totalPages > prevState.lastPage + 1;
+
+ return {
+ ...prevState,
+ lastPage: hasMore ? prevState.lastPage + 1 : prevState.lastPage,
+ hasMore,
+ };
+ }
+ default:
+ return prevState;
+ }
+ },
+ initialSearchResultState,
+ );
+ const algoliaClient = algoliaSearch(appId, apiKey);
+ const algoliaHelper = algoliaSearchHelper(algoliaClient, indexName, {
+ hitsPerPage: 15,
+ advancedSyntax: true,
+ facets: searchVersion ? ['version'] : [],
+ });
+
+ algoliaHelper.on(
+ 'result',
+ ({results: {query, hits, page, nbHits, nbPages}}) => {
+ if (query === '' || !(hits instanceof Array)) {
+ searchResultStateDispatcher({type: 'reset'});
+ return;
+ }
+
+ const sanitizeValue = (value) => {
+ return value.replace(
+ /algolia-docsearch-suggestion--highlight/g,
+ 'search-result-match',
+ );
+ };
+
+ const items = hits.map(
+ ({
+ url,
+ _highlightResult: {hierarchy},
+ _snippetResult: snippet = {},
+ }) => {
+ const {pathname, hash} = new URL(url);
+ const titles = Object.keys(hierarchy).map((key) => {
+ return sanitizeValue(hierarchy[key].value);
+ });
+
+ return {
+ title: titles.pop(),
+ url: pathname + hash,
+ summary: snippet.content
+ ? `${sanitizeValue(snippet.content.value)}...`
+ : '',
+ breadcrumbs: titles,
+ };
+ },
+ );
+
+ searchResultStateDispatcher({
+ type: 'update',
+ value: {
+ items,
+ query,
+ totalResults: nbHits,
+ totalPages: nbPages,
+ lastPage: page,
+ hasMore: nbPages > page + 1,
+ loading: false,
+ },
+ });
+ },
+ );
+
+ const [loaderRef, setLoaderRef] = useState(null);
+ const prevY = useRef(0);
+ const observer = useRef(
+ ExecutionEnvironment.canUseDOM &&
+ new IntersectionObserver(
+ (entries) => {
+ const {
+ isIntersecting,
+ boundingClientRect: {y: currentY},
+ } = entries[0];
+
+ if (isIntersecting && prevY.current > currentY) {
+ searchResultStateDispatcher({type: 'advance'});
+ }
+
+ prevY.current = currentY;
+ },
+ {threshold: 1},
+ ),
+ );
+
+ const getTitle = () =>
+ searchQuery
+ ? `Search results for "${searchQuery}"`
+ : 'Search the documentation';
+
+ const makeSearch = (page = 0) => {
+ if (searchVersion) {
+ algoliaHelper
+ .setQuery(searchQuery)
+ .addFacetRefinement('version', searchVersion)
+ .setPage(page)
+ .search();
+ } else {
+ algoliaHelper.setQuery(searchQuery).setPage(page).search();
+ }
+ };
+
+ const handleSearchInputChange = (e) => {
+ const searchInputValue = e.target.value;
+
+ if (e.target.tagName === 'SELECT') {
+ setSearchVersion(searchInputValue);
+ } else {
+ setSearchQuery(searchInputValue);
+ }
+ };
+
+ useEffect(() => {
+ if (!loaderRef) {
+ return undefined;
+ }
+
+ observer.current.observe(loaderRef);
+
+ return () => {
+ observer.current.unobserve(loaderRef);
+ };
+ }, [loaderRef]);
+
+ useEffect(() => {
+ updateSearchPath(searchQuery);
+
+ searchResultStateDispatcher({type: 'reset'});
+
+ if (searchQuery) {
+ searchResultStateDispatcher({type: 'loading'});
+
+ setTimeout(() => {
+ makeSearch();
+ }, 300);
+ }
+ }, [searchQuery, searchVersion]);
+
+ useEffect(() => {
+ if (!searchResultState.lastPage || searchResultState.lastPage === 0) {
+ return;
+ }
+
+ makeSearch(searchResultState.lastPage);
+ }, [searchResultState.lastPage]);
+
+ return (
+ No results were found{getTitle()}
+
+
+
+