refactor(theme-search-algolia): migrate package to TS (#5935)

This commit is contained in:
Armano 2021-11-16 20:35:09 +01:00 committed by GitHub
parent 284cdabb0a
commit 425144afc7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 301 additions and 91 deletions

View file

@ -14,6 +14,7 @@ packages/docusaurus/lib/
packages/docusaurus-*/lib/*
packages/docusaurus-*/lib-next/
packages/docusaurus-plugin-ideal-image/copyUntypedFiles.js
packages/docusaurus-theme-search-algolia/copyUntypedFiles.js
packages/create-docusaurus/lib/*
packages/create-docusaurus/templates/facebook/.eslintrc.js

View file

@ -15,6 +15,6 @@ const srcDir = path.resolve(__dirname, 'src');
const libDir = path.resolve(__dirname, 'lib');
fs.copySync(srcDir, libDir, {
filter(filepath) {
return !/__tests__/.test(filepath) && !/\.ts$/.test(filepath);
return !/__tests__/.test(filepath) && !/\.tsx?$/.test(filepath);
},
});

View file

@ -15,7 +15,7 @@ const CodeDirPaths = [
path.join(__dirname, 'lib-next'),
// TODO other themes should rather define their own translations in the future?
path.join(__dirname, '..', 'docusaurus-theme-common', 'lib'),
path.join(__dirname, '..', 'docusaurus-theme-search-algolia', 'src', 'theme'),
path.join(__dirname, '..', 'docusaurus-theme-search-algolia', 'lib', 'theme'),
path.join(__dirname, '..', 'docusaurus-theme-live-codeblock', 'src', 'theme'),
path.join(__dirname, '..', 'docusaurus-plugin-pwa', 'src', 'theme'),
];

View file

@ -22,6 +22,8 @@ export {createStorageSlot, listStorageKeys} from './utils/storageUtils';
export {useAlternatePageUtils} from './utils/useAlternatePageUtils';
export {useContextualSearchFilters} from './utils/useContextualSearchFilters';
export {
parseCodeBlockTitle,
parseLanguage,

View file

@ -5,21 +5,18 @@
* LICENSE file in the root directory of this source tree.
*/
import {useAllDocsData, useActivePluginAndVersion} from '@theme/hooks/useDocs';
import {
useDocsPreferredVersionByPluginId,
DEFAULT_SEARCH_TAG,
docVersionSearchTag,
} from '@docusaurus/theme-common';
import {useDocsPreferredVersionByPluginId} from './docsPreferredVersion/useDocsPreferredVersion';
import {docVersionSearchTag, DEFAULT_SEARCH_TAG} from './searchUtils';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
type ContextualSearchFilters = {
export type useContextualSearchFiltersReturns = {
locale: string;
tags: string[];
};
// We may want to support multiple search engines, don't couple that to Algolia/DocSearch
// Maybe users will want to use its own search engine solution
export default function useContextualSearchFilters(): ContextualSearchFilters {
export function useContextualSearchFilters(): useContextualSearchFiltersReturns {
const {i18n} = useDocusaurusContext();
const allDocsData = useAllDocsData();
const activePluginAndVersion = useActivePluginAndVersion();

View file

@ -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.
*/
const path = require('path');
const fs = require('fs-extra');
/**
* Copy all untyped and static assets files to lib.
*/
const srcDir = path.resolve(__dirname, 'src');
const libDir = path.resolve(__dirname, 'lib');
fs.copySync(srcDir, libDir, {
filter(filepath) {
return !/__tests__/.test(filepath) && !/\.tsx?$/.test(filepath);
},
});

View file

@ -2,7 +2,8 @@
"name": "@docusaurus/theme-search-algolia",
"version": "2.0.0-beta.9",
"description": "Algolia search component for Docusaurus.",
"main": "src/index.js",
"main": "lib/index.js",
"types": "src/theme-search-algolia.d.ts",
"publishConfig": {
"access": "public"
},
@ -12,6 +13,12 @@
"directory": "packages/docusaurus-theme-search-algolia"
},
"license": "MIT",
"scripts": {
"build": "yarn build:server && yarn build:browser && yarn build:copy",
"build:server": "tsc --project tsconfig.server.json",
"build:browser": "tsc --project tsconfig.browser.json",
"build:copy": "node copyUntypedFiles.js"
},
"dependencies": {
"@docsearch/react": "^3.0.0-alpha.39",
"@docusaurus/core": "2.0.0-beta.9",
@ -24,6 +31,10 @@
"eta": "^1.12.3",
"lodash": "^4.17.20"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "2.0.0-beta.9",
"fs-extra": "^10.0.0"
},
"peerDependencies": {
"react": "^16.8.4 || ^17.0.0",
"react-dom": "^16.8.4 || ^17.0.0"

View file

@ -5,26 +5,33 @@
* LICENSE file in the root directory of this source tree.
*/
const path = require('path');
const fs = require('fs');
const eta = require('eta');
const {normalizeUrl, getSwizzledComponent} = require('@docusaurus/utils');
const openSearchTemplate = require('./templates/opensearch');
const {validateThemeConfig} = require('./validateThemeConfig');
const {memoize} = require('lodash');
import path from 'path';
import fs from 'fs';
import {defaultConfig, compile} from 'eta';
import {normalizeUrl, getSwizzledComponent} from '@docusaurus/utils';
import openSearchTemplate from './templates/opensearch';
import {memoize} from 'lodash';
import type {DocusaurusContext, Plugin} from '@docusaurus/types';
const getCompiledOpenSearchTemplate = memoize(() => {
return eta.compile(openSearchTemplate.trim());
return compile(openSearchTemplate.trim());
});
function renderOpenSearchTemplate(data) {
function renderOpenSearchTemplate(data: {
title: string;
url: string;
favicon: string | null;
}) {
const compiled = getCompiledOpenSearchTemplate();
return compiled(data, eta.defaultConfig);
return compiled(data, defaultConfig);
}
const OPEN_SEARCH_FILENAME = 'opensearch.xml';
function theme(context) {
export default function theme(
context: DocusaurusContext & {baseUrl: string},
): Plugin<void> {
const {
baseUrl,
siteConfig: {title, url, favicon},
@ -37,12 +44,16 @@ function theme(context) {
return {
name: 'docusaurus-theme-search-algolia',
getPathsToWatch() {
return [pagePath];
},
getThemePath() {
return path.resolve(__dirname, './theme');
},
getPathsToWatch() {
return [pagePath];
getTypeScriptThemePath() {
return path.resolve(__dirname, '..', 'src', 'theme');
},
async contentLoaded({actions: {addRoute}}) {
@ -87,6 +98,4 @@ function theme(context) {
};
}
module.exports = theme;
theme.validateThemeConfig = validateThemeConfig;
export {validateThemeConfig} from './validateThemeConfig';

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
module.exports = `
export default `
<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"
xmlns:moz="http://www.mozilla.org/2006/browser/search/">

View file

@ -0,0 +1,49 @@
/**
* 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.
*/
declare module '@docusaurus/theme-search-algolia' {
export type Options = never;
}
declare module '@theme/hooks/useSearchQuery' {
export interface SearchQuery {
searchQuery: string;
setSearchQuery(newSearchQuery: string): void;
generateSearchPageLink(targetSearchQuery: string): string;
}
export default function useSearchQuery(): SearchQuery;
}
declare module '@theme/hooks/useAlgoliaContextualFacetFilters' {
export type useAlgoliaContextualFacetFiltersReturns = [string, string[]];
export default function useAlgoliaContextualFacetFilters(): useAlgoliaContextualFacetFiltersReturns;
}
declare module '@theme/SearchPage' {
const SearchPage: () => JSX.Element;
export default SearchPage;
}
declare module '@theme/SearchMetadata' {
export type SearchMetadataProps = {
readonly locale?: string;
readonly version?: string;
readonly tag?: string;
};
const SearchMetadata: (props: SearchMetadataProps) => JSX.Element;
export default SearchMetadata;
}
declare module '@theme/SearchBar' {
const SearchBar: () => JSX.Element;
export default SearchBar;
}

View file

@ -4,6 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/* eslint-disable @typescript-eslint/ban-ts-comment */
import React, {useState, useRef, useCallback, useMemo} from 'react';
import {createPortal} from 'react-dom';
@ -19,13 +20,42 @@ import useAlgoliaContextualFacetFilters from '@theme/hooks/useAlgoliaContextualF
import {translate} from '@docusaurus/Translate';
import styles from './styles.module.css';
let DocSearchModal = null;
import type {
DocSearchModal as DocSearchModalType,
DocSearchModalProps,
} from '@docsearch/react';
import type {
InternalDocSearchHit,
StoredDocSearchHit,
} from '@docsearch/react/dist/esm/types';
import type {AutocompleteState} from '@algolia/autocomplete-core';
function Hit({hit, children}) {
type DocSearchProps = Omit<
DocSearchModalProps,
'onClose' | 'initialScrollY'
> & {
contextualSearch?: string;
externalUrlRegex?: string;
};
let DocSearchModal: typeof DocSearchModalType | null = null;
function Hit({
hit,
children,
}: {
hit: InternalDocSearchHit | StoredDocSearchHit;
children: React.ReactNode;
}) {
return <Link to={hit.url}>{children}</Link>;
}
function ResultsFooter({state, onClose}) {
type ResultsFooterProps = {
state: AutocompleteState<InternalDocSearchHit>;
onClose: () => void;
};
function ResultsFooter({state, onClose}: ResultsFooterProps) {
const {generateSearchPageLink} = useSearchQuery();
return (
@ -35,7 +65,11 @@ function ResultsFooter({state, onClose}) {
);
}
function DocSearch({contextualSearch, externalUrlRegex, ...props}) {
function DocSearch({
contextualSearch,
externalUrlRegex,
...props
}: DocSearchProps) {
const {siteMetadata} = useDocusaurusContext();
const contextualSearchFacetFilters = useAlgoliaContextualFacetFilters();
@ -56,10 +90,12 @@ function DocSearch({contextualSearch, externalUrlRegex, ...props}) {
const {withBaseUrl} = useBaseUrlUtils();
const history = useHistory();
const searchContainer = useRef(null);
const searchButtonRef = useRef(null);
const searchContainer = useRef<HTMLDivElement | null>(null);
const searchButtonRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [initialQuery, setInitialQuery] = useState(null);
const [initialQuery, setInitialQuery] = useState<string | undefined>(
undefined,
);
const importDocSearchModalIfNeeded = useCallback(() => {
if (DocSearchModal) {
@ -67,7 +103,9 @@ function DocSearch({contextualSearch, externalUrlRegex, ...props}) {
}
return Promise.all([
// @ts-ignore
import('@docsearch/react/modal'),
// @ts-ignore
import('@docsearch/react/style'),
import('./styles.css'),
]).then(([{DocSearchModal: Modal}]) => {
@ -88,7 +126,7 @@ function DocSearch({contextualSearch, externalUrlRegex, ...props}) {
const onClose = useCallback(() => {
setIsOpen(false);
searchContainer.current.remove();
searchContainer.current?.remove();
}, [setIsOpen]);
const onInput = useCallback(
@ -102,35 +140,38 @@ function DocSearch({contextualSearch, externalUrlRegex, ...props}) {
);
const navigator = useRef({
navigate({itemUrl}) {
navigate({itemUrl}: {itemUrl?: string}) {
// Algolia results could contain URL's from other domains which cannot
// be served through history and should navigate with window.location
if (isRegexpStringMatch(externalUrlRegex, itemUrl)) {
window.location.href = itemUrl;
window.location.href = itemUrl!;
} else {
history.push(itemUrl);
history.push(itemUrl!);
}
},
}).current;
const transformItems = useRef((items) => {
return items.map((item) => {
// If Algolia contains a external domain, we should navigate without relative URL
if (isRegexpStringMatch(externalUrlRegex, item.url)) {
return item;
}
const transformItems = useRef<DocSearchModalProps['transformItems']>(
(items) => {
return 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}`),
};
});
}).current;
// We transform the absolute URL into a relative URL.
const url = new URL(item.url);
return {
...item,
url: withBaseUrl(`${url.pathname}${url.hash}`),
};
});
},
).current;
const resultsFooterComponent = useMemo(
() => (footerProps) => <ResultsFooter {...footerProps} onClose={onClose} />,
() => (footerProps: ResultsFooterProps) =>
<ResultsFooter {...footerProps} onClose={onClose} />,
[onClose],
);
@ -188,6 +229,8 @@ function DocSearch({contextualSearch, externalUrlRegex, ...props}) {
</div>
{isOpen &&
DocSearchModal &&
searchContainer.current &&
createPortal(
<DocSearchModal
onClose={onClose}
@ -209,6 +252,7 @@ function DocSearch({contextualSearch, externalUrlRegex, ...props}) {
function SearchBar() {
const {siteConfig} = useDocusaurusContext();
// @ts-ignore
return <DocSearch {...siteConfig.themeConfig.algolia} />;
}

View file

@ -8,9 +8,14 @@
import React from 'react';
import Head from '@docusaurus/Head';
import type {SearchMetadataProps} from '@theme/SearchMetadata';
// Override default/agnostic SearchMetas to use Algolia-specific metadata
export default function AlgoliaSearchMetadata({locale, version, tag}) {
function SearchMetadata({
locale,
version,
tag,
}: SearchMetadataProps): JSX.Element {
// Seems safe to consider here the locale is the language,
// as the existing docsearch:language filter is afaik a regular string-based filter
const language = locale;
@ -23,3 +28,5 @@ export default function AlgoliaSearchMetadata({locale, version, tag}) {
</Head>
);
}
export default SearchMetadata;

View file

@ -6,6 +6,7 @@
*/
/* eslint-disable jsx-a11y/no-autofocus */
/* eslint-disable @typescript-eslint/ban-ts-comment */
import React, {useEffect, useState, useReducer, useRef} from 'react';
@ -32,7 +33,7 @@ import styles from './styles.module.css';
// Very simple pluralization: probably good enough for now
function useDocumentsFoundPlural() {
const {selectMessage} = usePluralForm();
return (count) =>
return (count: number) =>
selectMessage(
count,
translate(
@ -52,14 +53,19 @@ function useDocsSearchVersionsHelpers() {
// State of the version select menus / algolia facet filters
// docsPluginId -> versionName map
const [searchVersions, setSearchVersions] = useState(() => {
return Object.entries(allDocsData).reduce((acc, [pluginId, pluginData]) => {
return {...acc, [pluginId]: pluginData.versions[0].name};
}, {});
});
const [searchVersions, setSearchVersions] = useState<Record<string, string>>(
() => {
return Object.entries(allDocsData).reduce(
(acc, [pluginId, pluginData]) => {
return {...acc, [pluginId]: pluginData.versions[0].name};
},
{},
);
},
);
// Set the value of a single select menu
const setSearchVersion = (pluginId, searchVersion) =>
const setSearchVersion = (pluginId: string, searchVersion: string) =>
setSearchVersions((s) => ({...s, [pluginId]: searchVersion}));
const versioningEnabled = Object.values(allDocsData).some(
@ -75,7 +81,11 @@ function useDocsSearchVersionsHelpers() {
}
// We want to display one select per versioned docs plugin instance
const SearchVersionSelectList = ({docsSearchVersionsHelpers}) => {
const SearchVersionSelectList = ({
docsSearchVersionsHelpers,
}: {
docsSearchVersionsHelpers: ReturnType<typeof useDocsSearchVersionsHelpers>;
}) => {
const versionedPluginEntries = Object.entries(
docsSearchVersionsHelpers.allDocsData,
)
@ -118,10 +128,32 @@ const SearchVersionSelectList = ({docsSearchVersionsHelpers}) => {
);
};
function SearchPage() {
type ResultDispatcherState = {
items: {
title: string;
url: string;
summary: string;
breadcrumbs: string[];
}[];
query: string | null;
totalResults: number | null;
totalPages: number | null;
lastPage: number | null;
hasMore: boolean | null;
loading: boolean | null;
};
type ResultDispatcher =
| {type: 'reset'; value?: undefined}
| {type: 'loading'; value?: undefined}
| {type: 'update'; value: ResultDispatcherState}
| {type: 'advance'; value?: undefined};
function SearchPage(): JSX.Element {
const {
siteConfig: {
themeConfig: {
// @ts-ignore
algolia: {appId, apiKey, indexName, externalUrlRegex},
},
},
@ -131,7 +163,7 @@ function SearchPage() {
const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers();
const {searchQuery, setSearchQuery} = useSearchQuery();
const initialSearchResultState = {
const initialSearchResultState: ResultDispatcherState = {
items: [],
query: null,
totalResults: null,
@ -141,8 +173,8 @@ function SearchPage() {
loading: null,
};
const [searchResultState, searchResultStateDispatcher] = useReducer(
(prevState, {type, value: state}) => {
switch (type) {
(prevState: ResultDispatcherState, data: ResultDispatcher) => {
switch (data.type) {
case 'reset': {
return initialSearchResultState;
}
@ -150,24 +182,24 @@ function SearchPage() {
return {...prevState, loading: true};
}
case 'update': {
if (searchQuery !== state.query) {
if (searchQuery !== data.value.query) {
return prevState;
}
return {
...state,
...data.value,
items:
state.lastPage === 0
? state.items
: prevState.items.concat(state.items),
data.value.lastPage === 0
? data.value.items
: prevState.items.concat(data.value.items),
};
}
case 'advance': {
const hasMore = prevState.totalPages > prevState.lastPage + 1;
const hasMore = prevState.totalPages! > prevState.lastPage! + 1;
return {
...prevState,
lastPage: hasMore ? prevState.lastPage + 1 : prevState.lastPage,
lastPage: hasMore ? prevState.lastPage! + 1 : prevState.lastPage,
hasMore,
};
}
@ -193,7 +225,7 @@ function SearchPage() {
return;
}
const sanitizeValue = (value) => {
const sanitizeValue = (value: string) => {
return value.replace(
/algolia-docsearch-suggestion--highlight/g,
'search-result-match',
@ -212,7 +244,7 @@ function SearchPage() {
});
return {
title: titles.pop(),
title: titles.pop()!,
url: isRegexpStringMatch(externalUrlRegex, parsedURL.href)
? parsedURL.href
: parsedURL.pathname + parsedURL.hash,
@ -239,7 +271,7 @@ function SearchPage() {
},
);
const [loaderRef, setLoaderRef] = useState(null);
const [loaderRef, setLoaderRef] = useState<HTMLDivElement | null>(null);
const prevY = useRef(0);
const observer = useRef(
ExecutionEnvironment.canUseDOM &&
@ -278,7 +310,7 @@ function SearchPage() {
description: 'The search page title for empty query',
});
const makeSearch = useDynamicCallback((page = 0) => {
const makeSearch = useDynamicCallback((page: number = 0) => {
algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', 'default');
algoliaHelper.addDisjunctiveFacetRefinement('language', currentLocale);
@ -299,9 +331,11 @@ function SearchPage() {
return undefined;
}
const currentObserver = observer.current;
currentObserver.observe(loaderRef);
return () => currentObserver.unobserve(loaderRef);
if (currentObserver) {
currentObserver.observe(loaderRef);
return () => currentObserver.unobserve(loaderRef);
}
return () => true;
}, [loaderRef]);
useEffect(() => {

View file

@ -5,10 +5,11 @@
* LICENSE file in the root directory of this source tree.
*/
import useContextualSearchFilters from '@theme/hooks/useContextualSearchFilters';
import type {useAlgoliaContextualFacetFiltersReturns} from '@theme/hooks/useAlgoliaContextualFacetFilters';
import {useContextualSearchFilters} from '@docusaurus/theme-common';
// Translate search-engine agnostic search filters to Algolia search filters
export default function useAlgoliaContextualFacetFilters() {
export default function useAlgoliaContextualFacetFilters(): useAlgoliaContextualFacetFiltersReturns {
const {locale, tags} = useContextualSearchFilters();
// seems safe to convert locale->language, see AlgoliaSearchMetadata comment

View file

@ -8,10 +8,11 @@
import {useHistory} from '@docusaurus/router';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import {useCallback, useEffect, useState} from 'react';
import type {SearchQuery} from '@theme/hooks/useSearchQuery';
const SEARCH_PARAM_QUERY = 'q';
function useSearchQuery() {
function useSearchQuery(): SearchQuery {
const history = useHistory();
const {
siteConfig: {baseUrl},
@ -28,7 +29,7 @@ function useSearchQuery() {
}, []);
const setSearchQuery = useCallback(
(newSearchQuery) => {
(newSearchQuery: string) => {
const searchParams = new URLSearchParams(window.location.search);
if (newSearchQuery) {
@ -46,7 +47,7 @@ function useSearchQuery() {
);
const generateSearchPageLink = useCallback(
(targetSearchQuery) => {
(targetSearchQuery: string) => {
// Refer to https://github.com/facebook/docusaurus/pull/2838
return `${baseUrl}search?q=${encodeURIComponent(targetSearchQuery)}`;
},

View file

@ -0,0 +1,10 @@
/**
* 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.
*/
/// <reference types="@docusaurus/module-type-aliases" />
/// <reference types="@docusaurus/theme-common" />
/// <reference types="@docusaurus/theme-classic" />

View file

@ -5,7 +5,8 @@
* LICENSE file in the root directory of this source tree.
*/
const {Joi} = require('@docusaurus/utils-validation');
import {Joi} from '@docusaurus/utils-validation';
import type {ThemeConfig, Validate, ValidationResult} from '@docusaurus/types';
const DEFAULT_CONFIG = {
contextualSearch: false, // future: maybe we want to enable this by default
@ -18,7 +19,7 @@ const DEFAULT_CONFIG = {
};
exports.DEFAULT_CONFIG = DEFAULT_CONFIG;
const Schema = Joi.object({
export const Schema = Joi.object({
algolia: Joi.object({
// Docusaurus attributes
contextualSearch: Joi.boolean().default(DEFAULT_CONFIG.contextualSearch),
@ -35,11 +36,13 @@ const Schema = Joi.object({
.required()
.unknown(), // DocSearch 3 is still alpha: don't validate the rest for now
});
exports.Schema = Schema;
exports.validateThemeConfig = function validateThemeConfig({
export function validateThemeConfig({
validate,
themeConfig,
}) {
}: {
validate: Validate<ThemeConfig>;
themeConfig: ThemeConfig;
}): ValidationResult<ThemeConfig> {
return validate(Schema, themeConfig);
};
}

View file

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "esnext",
"jsx": "react-native"
},
"include": ["src/theme/", "src/*.d.ts"]
}

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["DOM", "ES2019"],
"rootDir": "src",
"baseUrl": "src",
"outDir": "lib"
}
}

View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/*.ts", "src/templates/*.ts"]
}