diff --git a/package.json b/package.json index 50a0e7d00..7f349d9b6 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "dayjs": "^1.10.4", "fathom-client": "^3.1.0", "fuse.js": "^6.4.6", + "interweave": "^13.1.0", + "meilisearch": "^0.32.3", "nanostores": "^0.7.4", "next": "13.2.1", "next-contentlayer": "^0.3.0", diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 32fbef9d2..442a67a4b 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -47,6 +47,7 @@ const ModalDialog: React.FC> = ({ css={[ tw`fixed top-0 right-0 bottom-0 left-0 select-none z-50`, tw`bg-black bg-opacity-50`, + tw`overflow-scroll`, ]} > diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 60bc04905..767f3e703 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -4,6 +4,7 @@ import { Home, Menu, X } from "react-feather"; import tw from "twin.macro"; import { Link } from "./Link"; import { Logo } from "./Logo"; +import { OpenSearchModalButton } from "@/components/Search"; import { MobileSidebar } from "./Sidebar"; import { ThemeSwitcher } from "./ThemeSwitcher"; @@ -55,9 +56,9 @@ export const MobileNav: React.FC = () => { - {/*
- -
*/} +
+ +
- - ); -}; - -const MAX_NUM_RESULTS = 5; - -export const SearchModal: React.FC<{ - fuse: Fuse; - closeModal: () => void; -}> = ({ fuse, closeModal }) => { - const [query, setQuery] = useState(""); - const [results, setResults] = useState([]); - const [selected, setSelected] = useState(0); - const router = useRouter(); - - const onPressUp = useCallback(() => { - setSelected(Math.max(0, selected - 1)); - }, [selected, results, setSelected]); - - const onPressDown = useCallback(() => { - setSelected(Math.min(results.length - 1, selected + 1)); - }, [selected, results, setSelected]); - - const handleEnter = useCallback(() => { - const item = results[selected]; - if (item != null) { - closeModal(); - router.push(item.slug); - } - }, [selected, results]); - - useEffect(() => { - const preventDefault = (fn: () => void) => (e: KeyboardEvent) => { - e.preventDefault(); - fn(); - }; - - const unsubscribe = tinykeys(window, { - Enter: () => handleEnter(), - - ArrowUp: () => onPressUp(), - "Control+k": () => onPressUp(), - "Control+p": preventDefault(() => onPressUp()), - - ArrowDown: () => onPressDown(), - "Control+j": preventDefault(() => onPressDown()), - "Control+n": preventDefault(() => onPressDown()), - }); - - return () => unsubscribe(); - }, [handleEnter, onPressUp, onPressDown]); - - useEffect(() => { - const results = fuse.search(query); - const pages = results.map(r => r.item).slice(0, MAX_NUM_RESULTS); - setResults(pages); - setSelected(Math.max(0, Math.min(pages.length - 1, selected))); - }, [query]); - - return ( -
{ - closeModal(); - }} - > -
e.stopPropagation()} - > - setQuery(e.target.value)} - autoFocus - tw="px-4 py-4 w-full bg-transparent focus:outline-none" - /> - -
-
    - {results.map((item, index) => ( -
  • setSelected(index)}> - { - closeModal(); - }} - css={[ - tw`flex items-center justify-between px-3 h-16 relative`, - index === selected - ? tw`text-pink-900 bg-pink-100` - : tw`text-gray-500`, - ]} - > -
    - {item.category != null && ( - - {item.category} - - )} - {item.title} -
    - - - -
  • - ))} -
-
-
-
- ); -}; diff --git a/src/components/Search/Modal.tsx b/src/components/Search/Modal.tsx new file mode 100644 index 000000000..582b662e1 --- /dev/null +++ b/src/components/Search/Modal.tsx @@ -0,0 +1,62 @@ +import { useDebouncedSearch } from "@/hooks/useDebouncedSearch"; +import { Search } from "@/types"; +import React from "react"; +import tw from "twin.macro"; +import NoResults from "./NoResults"; +import QueryInput from "./QueryInput"; +import Results from "./Results"; + +interface Props { + closeModal: () => void; +} + +const Modal: React.FC = ({ closeModal }) => { + const searchParams = { + limit: 10, + attributesToHighlight: ["*"], + highlightPreTag: "", + highlightPostTag: "", + }; + const { clearResponse, query, setQuery, results } = useDebouncedSearch< + Search.Document, + Search.Result + >( + process.env.NEXT_PUBLIC_MEILISEARCH_HOST ?? "", + process.env.NEXT_PUBLIC_MEILISEARCH_READ_API_KEY ?? "", + process.env.NEXT_PUBLIC_MEILISEARCH_INDEX_NAME ?? "", + searchParams, + 200, + ); + + return ( +
{ + closeModal(); + }} + > +
e.stopPropagation()} + css={[tw`bg-background border rounded-lg w-full md:w-1/2 mx-auto`]} + > +
+ +
+
+ {results && + (Object.keys(results).length === 0 ? ( + + ) : ( + + ))} +
+
+
+ ); +}; + +export default Modal; diff --git a/src/components/Search/NoResults.tsx b/src/components/Search/NoResults.tsx new file mode 100644 index 000000000..f0dae9cb6 --- /dev/null +++ b/src/components/Search/NoResults.tsx @@ -0,0 +1,43 @@ +import { DiscordIcon } from "@/components/Icons"; +import { Link } from "@/components/Link"; +import React from "react"; +import { HelpCircle, Mail } from "react-feather"; +import tw, { styled } from "twin.macro"; + +const ContactButton = styled(Link)` + ${[ + tw`flex flex-row items-center gap-2 p-2`, + tw`border border-solid rounded-lg`, + tw`hover:bg-pink-100`, + tw`[&>svg]:w-8`, + tw`[&>svg]:h-8`, + ]} +`; + +const NoResults: React.FC = () => { + return ( +
+ +
+

+ We couldn't find what you're searching for. +

+
+

Reach out to us if you need help:

+
+ + + Discord + + + + Email + +
+
+
+
+ ); +}; + +export default NoResults; diff --git a/src/components/Search/OpenModalButton.tsx b/src/components/Search/OpenModalButton.tsx new file mode 100644 index 000000000..15d661ae8 --- /dev/null +++ b/src/components/Search/OpenModalButton.tsx @@ -0,0 +1,28 @@ +import { searchStore } from "@/store"; +import React from "react"; +import { Search as SearchIcon } from "react-feather"; +import tw from "twin.macro"; + +const OpenModalButton: React.FC = () => { + return ( + <> + + + ); +}; + +export default OpenModalButton; diff --git a/src/components/Search/QueryInput.tsx b/src/components/Search/QueryInput.tsx new file mode 100644 index 000000000..12fb2e969 --- /dev/null +++ b/src/components/Search/QueryInput.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { Search as SearchIcon } from "react-feather"; +import tw from "twin.macro"; + +interface Props { + clearResponse: () => void; + query: string; + setQuery: (q: string) => void; +} + +const QueryInput: React.FC = ({ clearResponse, query, setQuery }) => { + return ( +
+
+ + + + setQuery(e.target.value)} + /> + + + +
+
+ ); +}; + +export default QueryInput; diff --git a/src/components/Search/Results.tsx b/src/components/Search/Results.tsx new file mode 100644 index 000000000..720154e81 --- /dev/null +++ b/src/components/Search/Results.tsx @@ -0,0 +1,72 @@ +import { Link } from "@/components/Link"; +import { Search } from "@/types"; +import { Markup } from "interweave"; +import React from "react"; +import tw from "twin.macro"; + +interface Props { + closeModal: () => void + results: Search.Result; +} + +const Results: React.FC = ({ closeModal, results }) => { + const withoutBaseUri = (slug: string) => { + const url = new URL(slug); + const { hash, pathname } = url; + return `${pathname}${hash}`; + }; + + // @FIXME: Indexer is grabbing #__next from anchor hrefs. This should be + // fixed upstream, but no harm in just hacking it in place for now. + const cleanSlug = (slug: string) => + withoutBaseUri(slug.replace("#__next", "")); + + return ( +
+ {Object.entries(results).map(([chapter, hits]) => { + return ( +
+

{chapter}

+
    + {hits.map(h => { + return ( +
  • + closeModal()} + css={[tw`flex flex-col font-medium hover:text-pink-700`]} + > + {h.hierarchies.join(" -> ")} + .rendered span]:bg-yellow-200`, + tw`[&>.rendered span]:p-1`, + tw`[&>.rendered span]:text-black`, + tw`[&>.rendered span]:dark:text-white`, + ]} + > + {h.text !== "" && ( + + )} + + +
  • + ); + })} +
+
+ ); + })} +
+ ); +}; + +export default Results; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx new file mode 100644 index 000000000..164188a26 --- /dev/null +++ b/src/components/Search/index.tsx @@ -0,0 +1,4 @@ +import SearchModal from "./Modal"; +import OpenSearchModalButton from "./OpenModalButton"; + +export { SearchModal, OpenSearchModalButton }; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 9cddb0afd..ab6e61ea9 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -7,7 +7,7 @@ import { Link } from "./Link"; import { Logo } from "./Logo"; import { ISidebarSection } from "@/types"; import { ScrollArea } from "./ScrollArea"; -import { Search } from "./Search"; +import { OpenSearchModalButton } from "@/components/Search"; import { ThemeSwitcher } from "./ThemeSwitcher"; export const Sidebar: React.FC = ({ ...props }) => { @@ -35,7 +35,7 @@ export const Sidebar: React.FC = ({ ...props }) => {
- +
diff --git a/src/hooks/useDebouncedSearch.ts b/src/hooks/useDebouncedSearch.ts new file mode 100644 index 000000000..f69d3f8d3 --- /dev/null +++ b/src/hooks/useDebouncedSearch.ts @@ -0,0 +1,135 @@ +import { Search } from "@/types"; +import { MeiliSearch, SearchParams, SearchResponse } from "meilisearch"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +type Transformer = ( + src: SearchResponse, +) => Result; + +/** + * Default transformer for the document structure created by + * https://github.com/meilisearch/docs-scraper. + */ +const defaultTransformResponse: Transformer< + Search.Document, + Search.Result +> = response => { + const { hits } = response; + const chapters = Array.from(new Set(hits.map(r => r.hierarchy_lvl0))); + return chapters.reduce((acc, curr) => { + acc[curr] = [ + ...hits + .filter(r => r.hierarchy_lvl0 === curr) + .map(hit => ({ + hierarchies: [ + // `hit.hierarchy_lvl0` is intentionally ignored here; we're + // grouping the rendered output by it so it's redundant. + // In practice, this means that we'll render: + // >> "Databases" + // >> "PostgreSQL" + // >> ... + // instead of: + // >> "Databases" + // >> "Databases -> PostgreSQL" + // >> ... + hit.hierarchy_lvl1, + hit.hierarchy_lvl2, + hit.hierarchy_lvl3, + hit.hierarchy_lvl4, + ].filter(h => h !== null), + slug: hit.url, + text: (hit._formatted && hit._formatted.content) ?? "", + })), + ]; + return acc; + }, {} as Search.Result); +}; + +/** + * This hook provides functionality for searching a MeiliSearch index. It + * exposes a `query` string and `setQuery` method for controlling an + * element in a debounced manner, a `results` object containing + * the search results, and a `clearResults` method for clearing search + * results. + * + * It can also take in a `transformResponse` method for transforming the + * raw response from Meilisearch into a structure you define. + */ +export const useDebouncedSearch = < + Response extends Record, + Result, +>( + host: string, + apiKey: string, + indexName: string, + params: SearchParams, + debounceMs: number = 500, + transformResponse: Transformer< + Response, + Result + > = defaultTransformResponse as Transformer, +) => { + if (host === "") { + console.error(`useDebouncedSearch.host is missing`); + } + if (apiKey === "") { + console.error(`useDebouncedSearch.apiKey is missing`); + } + if (indexName === "") { + console.error(`useDebouncedSearch.indexName is missing`); + } + + // Controlled input for rendering + const [rawInput, setRawInput] = useState(""); + // Actual query from input that gets sent in search requests + const [query, setQuery] = useState(""); + + const [results, setResults] = useState(null); + + // Get index + const index = useMemo(() => { + const meilisearch = new MeiliSearch({ + host, + apiKey, + }); + return meilisearch.index(indexName); + }, [host, apiKey, indexName]); + + // Get search response + const search = useCallback(async () => { + if (query === "") { + return; + } + try { + const response = await index.search(query, params); + setResults(transformResponse(response)); + } catch (e) { + console.error(`Search for query "${query}" failed (${e})`); + } + }, [query]); + + // Perform search + useEffect(() => { + if (query === "") { + return; + } + search(); + }, [query, search]); + + // Debounce query input + useEffect(() => { + const debouncedFn = setTimeout(() => setQuery(rawInput), debounceMs); + return () => clearTimeout(debouncedFn); + }, [rawInput, debounceMs]); + + return { + query: rawInput, + clearResponse: () => { + setQuery(""); + setRawInput(""); + setResults(null); + }, + setQuery: setRawInput, + results, + }; +}; diff --git a/src/layouts/Page.tsx b/src/layouts/Page.tsx index fe9c7a206..8e78532ee 100644 --- a/src/layouts/Page.tsx +++ b/src/layouts/Page.tsx @@ -3,14 +3,14 @@ import Fuse from "fuse.js"; import React, { PropsWithChildren, useEffect, useMemo } from "react"; import tinykeys from "tinykeys"; import "twin.macro"; -import { Modal } from "../components/Modal"; +import { Modal } from "@/components/Modal"; import { MobileNav, Nav } from "../components/Nav"; -import { SearchModal } from "../components/Search"; +import { SearchModal } from "@/components/Search"; import { Props as SEOProps, SEO } from "../components/SEO"; import { Sidebar } from "../components/Sidebar"; import { sidebarContent } from "../data/sidebar"; import { Background } from "../pages"; -import { searchStore } from "../store"; +import { searchStore } from "@/store"; export interface Props { seo?: SEOProps; @@ -65,7 +65,7 @@ export const Page: React.FC> = props => { isOpen={isSearchOpen} onClose={() => searchStore.set(false)} > - searchStore.set(false)} /> + searchStore.set(false)} /> ); diff --git a/src/store.ts b/src/store/index.ts similarity index 100% rename from src/store.ts rename to src/store/index.ts diff --git a/src/types.ts b/src/types.ts index 4fd7f004b..7b3ea0486 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,3 +15,21 @@ export interface ISidebarSection { title?: string; pages: IPage[]; } + +export namespace Search { + interface ResultItem { + hierarchies: string[]; + slug: string; + text: string; + } + export type Result = Record; + export interface Document { + hierarchy_lvl0: string; + hierarchy_lvl1: string; + hierarchy_lvl2: string; + hierarchy_lvl3: string; + hierarchy_lvl4: string; + url: string; + content: string; + } +} diff --git a/yarn.lock b/yarn.lock index ac9a4f470..f1ca2865b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2533,6 +2533,13 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== +cross-fetch@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" + integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== + dependencies: + node-fetch "2.6.7" + css-color-keywords@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05" @@ -2694,6 +2701,11 @@ escalade@^3.1.1: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== +escape-html@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" @@ -3122,6 +3134,13 @@ inline-style-parser@0.1.1: resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1" integrity sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q== +interweave@^13.1.0: + version "13.1.0" + resolved "https://registry.yarnpkg.com/interweave/-/interweave-13.1.0.tgz#4b7a0a87a7eb32001bef64525f68d95296dee03c" + integrity sha512-JIDq0+2NYg0cgL7AB26fBcV0yZdiJvPDBp+aF6k8gq6Cr1kH5Gd2/Xqn7j8z+TGb8jCWZn739jzalCz+nPYwcA== + dependencies: + escape-html "^1.0.3" + intl-messageformat@^10.1.0: version "10.3.1" resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-10.3.1.tgz#99b280b706e4eeb232458dcb1b5758e80abf919c" @@ -3608,6 +3627,13 @@ mdx-bundler@^9.2.1: uuid "^8.3.2" vfile "^5.3.2" +meilisearch@^0.32.3: + version "0.32.3" + resolved "https://registry.yarnpkg.com/meilisearch/-/meilisearch-0.32.3.tgz#cbc224014c3dddc818090f4b501029993f117b6a" + integrity sha512-EOgfBuRE5SiIPIpEDYe2HO0D7a4z5bexIgaAdJFma/dH5hx1kwO+u/qb2g3qKyjG+iA3l8MlmTj/Xd72uahaAw== + dependencies: + cross-fetch "^3.1.5" + merge2@^1.3.0: version "1.4.1" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" @@ -4112,6 +4138,13 @@ node-domexception@^1.0.0: resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== +node-fetch@2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + node-fetch@^3.0.0: version "3.3.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.0.tgz#37e71db4ecc257057af828d523a7243d651d91e4" @@ -4973,6 +5006,11 @@ toml@^3.0.0: resolved "https://registry.yarnpkg.com/toml/-/toml-3.0.0.tgz#342160f1af1904ec9d204d03a5d61222d762c5ee" integrity sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w== +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + trim-lines@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" @@ -5196,6 +5234,19 @@ web-streams-polyfill@^3.0.3: resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6" integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q== +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"