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 ( + +
+

{getTitle()}

+ +
e.preventDefault()}> +
+ +
+ + {versioningEnabled && ( +
+ +
+ )} +
+ +
+
+ {!!searchResultState.totalResults && ( + {searchResultState.totalResults} documents found + )} +
+ +
+ + + + + + + + + +
+
+ + {searchResultState.items.length > 0 ? ( +
+ {searchResultState.items.map( + ({title, url, summary, breadcrumbs}, i) => ( +
+ + + {breadcrumbs.length > 0 && ( + + )} + + {summary && ( +

+ )} +

+ ), + )} +
+ ) : ( + [ + searchQuery && !searchResultState.loading && ( +

No results were found

+ ), + !!searchResultState.loading && ( +
+ ), + ] + )} + + {searchResultState.hasMore && ( +
+ Fetching new results... +
+ )} +
+ + ); +} + +export default Search; diff --git a/packages/docusaurus-theme-search-algolia/src/pages/search/styles.module.css b/packages/docusaurus-theme-search-algolia/src/pages/search/styles.module.css new file mode 100644 index 0000000000..47bcb3c39e --- /dev/null +++ b/packages/docusaurus-theme-search-algolia/src/pages/search/styles.module.css @@ -0,0 +1,104 @@ +/** + * 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. + */ + +.searchQueryInput, +.searchVersionInput { + border-radius: var(--ifm-global-radius); + border: var(--ifm-global-border-width) solid + var(--ifm-color-content-secondary); + font-size: var(--ifm-font-size-base); + padding: 0.5rem; + width: 100%; + background: #fff; +} + +.searchResultsColumn { + font-size: 0.9rem; +} + +.searchLogoColumn { + text-align: right; +} + +.algoliaLogo { + max-width: 150px; +} + +.algoliaLogoPathFill { + fill: var(--ifm-font-color-base); +} + +.searchResultItem { + padding: 1rem 0; + border-bottom: 1px solid #dfe3e8; +} + +.searchResultItemHeading { + font-size: var(--ifm-h2-font-size); +} + +.searchResultItemPath { + font-size: 0.8rem; + color: var(--ifm-color-content-secondary); + display: block; +} + +.searchResultItemSummary { + margin: 0.5rem 0 0 0; + font-style: italic; +} + +@media only screen and (max-width: 996px) { + .searchQueryColumn { + max-width: 60% !important; + } + + .searchVersionColumn { + max-width: 40% !important; + } + + .algoliaLogo { + width: 100%; + } + + .searchResultsColumn { + max-width: 60% !important; + } + + .searchLogoColumn { + overflow: hidden; + max-width: 40% !important; + padding-left: 0 !important; + } +} + +.loadingSpinner { + pointer-events: none; + width: 3rem; + height: 3rem; + border: 0.4em solid transparent; + border-color: #eee; + border-top-color: var(--ifm-color-primary); + border-radius: 50%; + animation: loadingspin 1s linear infinite; + margin: 0 auto; +} + +@keyframes loadingspin { + 100% { + transform: rotate(360deg); + } +} + +.loader { + margin-top: 2rem; +} + +:global(.search-result-match) { + background: rgba(255, 215, 142, 0.22); + padding: 0.09em 0; +} diff --git a/packages/docusaurus-theme-search-algolia/src/templates/opensearch.js b/packages/docusaurus-theme-search-algolia/src/templates/opensearch.js new file mode 100644 index 0000000000..adb802ee7e --- /dev/null +++ b/packages/docusaurus-theme-search-algolia/src/templates/opensearch.js @@ -0,0 +1,20 @@ +/** + * 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. + */ + +module.exports = ` + + + <%= it.title %> + Search <%= it.title %> + UTF-8 + <%= it.favicon %> + + + <%= it.url %> + +`; diff --git a/packages/docusaurus-theme-search-algolia/src/theme/SearchBar/index.js b/packages/docusaurus-theme-search-algolia/src/theme/SearchBar/index.js index 6815d4ddca..ddac0c6e16 100644 --- a/packages/docusaurus-theme-search-algolia/src/theme/SearchBar/index.js +++ b/packages/docusaurus-theme-search-algolia/src/theme/SearchBar/index.js @@ -10,6 +10,7 @@ import classnames from 'classnames'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import {useHistory} from '@docusaurus/router'; +import useSearchQuery from '@theme/hooks/useSearchQuery'; import './styles.css'; @@ -21,6 +22,7 @@ const Search = (props) => { themeConfig: {algolia}, } = siteConfig; const history = useHistory(); + const {navigateToSearchPage} = useSearchQuery(); function initAlgolia(focus) { window.docsearch({ @@ -29,6 +31,9 @@ const Search = (props) => { indexName: algolia.indexName, inputSelector: '#search_input_react', algoliaOptions: algolia.algoliaOptions, + autocompleteOptions: { + autoselect: false, + }, // Override algolia's default selection event, allowing us to do client-side // navigation and avoiding a full page refresh. handleSelected: (_input, _event, suggestion) => { @@ -87,6 +92,12 @@ const Search = (props) => { loadAlgolia(needFocus); }); + const handleSearchInputPressEnter = useCallback((e) => { + if (e.key === 'Enter') { + navigateToSearchPage(e.target.value); + } + }); + return (
{ onMouseOver={handleSearchInput} onFocus={handleSearchInput} onBlur={handleSearchInputBlur} + onKeyDown={handleSearchInputPressEnter} ref={searchBarRef} />
diff --git a/packages/docusaurus-theme-search-algolia/src/theme/hooks/useSearchQuery.js b/packages/docusaurus-theme-search-algolia/src/theme/hooks/useSearchQuery.js new file mode 100644 index 0000000000..9a2b38dcb2 --- /dev/null +++ b/packages/docusaurus-theme-search-algolia/src/theme/hooks/useSearchQuery.js @@ -0,0 +1,41 @@ +/** + * 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 {useHistory, useLocation} from '@docusaurus/router'; +import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; + +const SEARCH_PARAM_QUERY = 'q'; + +function useSearchQuery() { + const history = useHistory(); + const location = useLocation(); + + return { + searchValue: + (ExecutionEnvironment.canUseDOM && + new URLSearchParams(location.search).get(SEARCH_PARAM_QUERY)) || + '', + updateSearchPath: (searchValue) => { + const searchParams = new URLSearchParams(location.search); + + if (searchValue) { + searchParams.set(SEARCH_PARAM_QUERY, searchValue); + } else { + searchParams.delete(SEARCH_PARAM_QUERY); + } + + history.replace({ + search: searchParams.toString(), + }); + }, + navigateToSearchPage: (searchValue) => { + history.push(`/search?q=${searchValue}`); + }, + }; +} + +export default useSearchQuery; diff --git a/packages/docusaurus/src/commands/build.ts b/packages/docusaurus/src/commands/build.ts index ddcd2ddcda..139b950037 100644 --- a/packages/docusaurus/src/commands/build.ts +++ b/packages/docusaurus/src/commands/build.ts @@ -10,7 +10,7 @@ import CopyWebpackPlugin from 'copy-webpack-plugin'; import fs from 'fs-extra'; import path from 'path'; import ReactLoadableSSRAddon from 'react-loadable-ssr-addon'; -import webpack, {Configuration, Plugin} from 'webpack'; +import webpack, {Configuration, Plugin, Stats} from 'webpack'; import {BundleAnalyzerPlugin} from 'webpack-bundle-analyzer'; import merge from 'webpack-merge'; import {STATIC_DIR_NAME} from '../constants'; @@ -35,7 +35,18 @@ function compile(config: Configuration[]): Promise { reject(new Error('Failed to compile with errors.')); } if (stats.hasWarnings()) { - stats.toJson('errors-warnings').warnings.forEach((warning) => { + // Custom filtering warnings (see https://github.com/webpack/webpack/issues/7841). + let warnings = stats.toJson('errors-warnings').warnings; + const warningsFilter = ((config[0].stats as Stats.ToJsonOptionsObject) + ?.warningsFilter || []) as any[]; + + if (Array.isArray(warningsFilter)) { + warnings = warnings.filter((warning) => + warningsFilter.every((str) => !warning.includes(str)), + ); + } + + warnings.forEach((warning) => { console.warn(warning); }); } diff --git a/packages/docusaurus/src/commands/start.ts b/packages/docusaurus/src/commands/start.ts index 04ee554fe1..52eb6ca8fd 100644 --- a/packages/docusaurus/src/commands/start.ts +++ b/packages/docusaurus/src/commands/start.ts @@ -124,42 +124,48 @@ export async function start( // https://webpack.js.org/configuration/dev-server const devServerConfig: WebpackDevServer.Configuration = { - compress: true, - clientLogLevel: 'error', - hot: true, - hotOnly: cliOptions.hotOnly, - // Use 'ws' instead of 'sockjs-node' on server since we're using native - // websockets in `webpackHotDevClient`. - transportMode: 'ws', - // Prevent a WS client from getting injected as we're already including - // `webpackHotDevClient`. - injectClient: false, - quiet: true, - headers: { - 'access-control-allow-origin': '*', - }, - publicPath: baseUrl, - watchOptions: { - ignored: /node_modules/, - poll: cliOptions.poll, - }, - historyApiFallback: { - rewrites: [{from: /\/*/, to: baseUrl}], - }, - disableHostCheck: true, - // Disable overlay on browser since we use CRA's overlay error reporting. - overlay: false, - host, - before: (app, server) => { - app.use(baseUrl, express.static(path.resolve(siteDir, STATIC_DIR_NAME))); + ...{ + compress: true, + clientLogLevel: 'error', + hot: true, + hotOnly: cliOptions.hotOnly, + // Use 'ws' instead of 'sockjs-node' on server since we're using native + // websockets in `webpackHotDevClient`. + transportMode: 'ws', + // Prevent a WS client from getting injected as we're already including + // `webpackHotDevClient`. + injectClient: false, + quiet: true, + headers: { + 'access-control-allow-origin': '*', + }, + publicPath: baseUrl, + watchOptions: { + ignored: /node_modules/, + poll: cliOptions.poll, + }, + historyApiFallback: { + rewrites: [{from: /\/*/, to: baseUrl}], + }, + disableHostCheck: true, + // Disable overlay on browser since we use CRA's overlay error reporting. + overlay: false, + host, + before: (app, server) => { + app.use( + baseUrl, + express.static(path.resolve(siteDir, STATIC_DIR_NAME)), + ); - // This lets us fetch source contents from webpack for the error overlay. - app.use(evalSourceMapMiddleware(server)); - // This lets us open files from the runtime error overlay. - app.use(errorOverlayMiddleware()); + // This lets us fetch source contents from webpack for the error overlay. + app.use(evalSourceMapMiddleware(server)); + // This lets us open files from the runtime error overlay. + app.use(errorOverlayMiddleware()); - // TODO: add plugins beforeDevServer and afterDevServer hook + // TODO: add plugins beforeDevServer and afterDevServer hook + }, }, + ...config.devServer, }; const compiler = webpack(config); const devServer = new WebpackDevServer(compiler, devServerConfig); diff --git a/yarn.lock b/yarn.lock index 600647349e..fb327eac44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3182,6 +3182,13 @@ ajv@^6.10.2, ajv@^6.12.0, ajv@^6.5.5: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +algoliasearch-helper@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.1.1.tgz#4cdfbaed6670d82626ac1fae001ccbf53192f497" + integrity sha512-Jkqlp8jezQRixf7sbQ2zFXHpdaT41g9sHBqT6pztv5nfDmg94K+pwesAy6UbxRY78IL54LIaV1FLttMtT+IzzA== + dependencies: + events "^1.1.1" + algoliasearch@^3.24.5: version "3.35.1" resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-3.35.1.tgz#297d15f534a3507cab2f5dfb996019cac7568f0c" @@ -6728,7 +6735,7 @@ eventemitter3@^4.0.0: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== -events@^1.1.0: +events@^1.1.0, events@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=