From 6f5a74c90352ddc696cbbf1c6bdc26e8220735a7 Mon Sep 17 00:00:00 2001 From: Brenden Matthews Date: Sat, 1 Oct 2022 15:32:01 -0500 Subject: [PATCH] Add fuzzy search to web docs. --- web/components/Docs.tsx | 106 ++++++++++--------- web/components/Header.tsx | 13 ++- web/components/Layout.tsx | 11 +- web/components/Search.tsx | 182 +++++++++++++++++++++++++++++++++ web/package.json | 2 + web/pages/config_settings.tsx | 7 +- web/pages/documents/[slug].tsx | 7 +- web/pages/index.tsx | 10 +- web/pages/lua.tsx | 7 +- web/pages/variables.tsx | 7 +- web/utils/doc-utils.ts | 10 +- web/utils/search.ts | 38 +++++++ web/yarn.lock | 10 ++ 13 files changed, 347 insertions(+), 63 deletions(-) create mode 100644 web/components/Search.tsx create mode 100644 web/utils/search.ts diff --git a/web/components/Docs.tsx b/web/components/Docs.tsx index 8bb89138..ec5f6eea 100644 --- a/web/components/Docs.tsx +++ b/web/components/Docs.tsx @@ -1,5 +1,7 @@ import { Documentation } from '../utils/doc-utils' -import { Link } from 'react-feather' +import { Link as LinkIcon } from 'react-feather' +import Link from 'next/link' +import { useRouter } from 'next/router' export interface DocsProps { docs: Documentation @@ -8,61 +10,71 @@ export interface DocsProps { } export default function Docs({ docs, braces, assign }: DocsProps) { + const router = useRouter() return (
-
+
- {docs.values.map((doc) => ( -
-
-
- - - -
-
-
- {braces && ${} - - {doc.name} - - {typeof doc.args != 'undefined' && doc.args.length > 0 && ( - <> - {assign && =} - - {doc.args.map((arg) => ( - - {arg} - - ))} - - - )} - {braces && }} + {docs.values.map((doc) => { + const target = router.asPath.endsWith(`#${doc.name}`) + return ( +
+
+
+ + + + +
-
- {typeof doc.default != 'undefined' && ( +
- Default:{' '} - - {doc.default} + {braces && ${} + + {doc.name} + {typeof doc.args != 'undefined' && doc.args.length > 0 && ( + <> + {assign && =} + + {doc.args.map((arg) => ( + + {arg} + + ))} + + + )} + {braces && }}
- )} +
+ {typeof doc.default != 'undefined' && ( +
+ Default:{' '} + + {doc.default} + +
+ )} +
-
- ))} + ) + })}
) diff --git a/web/components/Header.tsx b/web/components/Header.tsx index 7b0a72f6..37006376 100644 --- a/web/components/Header.tsx +++ b/web/components/Header.tsx @@ -8,9 +8,12 @@ type HeaderProps = { name: string darkMode: boolean setDarkMode: (state: boolean) => void + searchIndex: SearchIndex } import * as React from 'react' +import Search from './Search' +import { SearchIndex } from '../utils/search' interface NavLinkProps { href: string @@ -33,7 +36,12 @@ const NavLink: React.FunctionComponent = (props) => { ) } -export default function Header({ name, darkMode, setDarkMode }: HeaderProps) { +export default function Header({ + name, + darkMode, + setDarkMode, + searchIndex, +}: HeaderProps) { const router = useRouter() return (
@@ -52,7 +60,8 @@ export default function Header({ name, darkMode, setDarkMode }: HeaderProps) {
)} - + +
diff --git a/web/components/Layout.tsx b/web/components/Layout.tsx index 89e3bdea..2b12a5d5 100644 --- a/web/components/Layout.tsx +++ b/web/components/Layout.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { SearchIndex } from '../utils/search' import Header from './Header' function darkModeDefault() { @@ -15,9 +16,10 @@ function darkModeDefault() { interface LayoutProps { children: React.ReactNode + searchIndex: SearchIndex } -export default function Layout({ children }: LayoutProps) { +export default function Layout({ children, searchIndex }: LayoutProps) { const [darkMode, setDarkMode] = useState(darkModeDefault()) useEffect(() => { @@ -45,7 +47,12 @@ export default function Layout({ children }: LayoutProps) { return (
-
+
diff --git a/web/components/Search.tsx b/web/components/Search.tsx new file mode 100644 index 00000000..4e89f403 --- /dev/null +++ b/web/components/Search.tsx @@ -0,0 +1,182 @@ +import Fuse from 'fuse.js' +import React, { Fragment, useState } from 'react' +import { Search as SearchIcon } from 'react-feather' +import { SearchIndex, SearchItem } from '../utils/search' +import { Dialog, Transition, Combobox } from '@headlessui/react' +import { useRouter } from 'next/router' + +export interface SearchProps { + index: SearchIndex +} + +interface SearchResultProps { + result: Fuse.FuseResult + selected: boolean + active: boolean +} + +const SearchResult: React.FunctionComponent = (props) => { + const excerpt = (s: string) => { + if (s.length < 100) { + return <>{s} + } else { + return <>{s.substring(0, 100)}… + } + } + const bg_for = (s: string) => { + const bg = 'p-1 rounded bg-opacity-20 ' + if (s === 'var') { + return bg + 'bg-blue-500' + } + if (s === 'config') { + return bg + 'bg-green-500' + } + if (s === 'lua') { + return bg + 'bg-red-500' + } + return bg + } + const selection = props.active ? 'bg-slate-300 dark:bg-slate-700' : '' + + return ( +
+
+
+ + {props.result.item.name} + +
+
+ {props.result.item.kind} +
+
+
+

{excerpt(props.result.item.desc)}

+
+
+ ) +} + +const Search: React.FunctionComponent = (props) => { + const router = useRouter() + const [searchText, setSearchText] = useState('') + const [selected, setSelected] = useState< + Fuse.FuseResult | undefined + >(undefined) + const [searchResults, setSearchResults] = useState< + Fuse.FuseResult[] + >([]) + const [fuse, setFuse] = useState(() => { + const options: Fuse.IFuseOptions = {} + return new Fuse( + props.index.list, + options, + Fuse.parseIndex(props.index.index) + ) + }) + let [isOpen, setIsOpen] = useState(false) + + const setSearch = (value: string) => { + setSearchText(value) + const searchResult = fuse.search(value) + setSearchResults(searchResult) + } + const onChange = (value: Fuse.FuseResult) => { + if (value.item.kind === 'var') { + router.push(`/variables#${value.item.name}`, undefined, { scroll: false }) + } + if (value.item.kind === 'config') { + router.push(`/config_settings#${value.item.name}`, undefined, { + scroll: false, + }) + } + if (value.item.kind === 'lua') { + router.push(`/lua#${value.item.name}`, undefined, { scroll: false }) + } + setIsOpen(false) + } + + const closeModal = () => { + setIsOpen(false) + } + + const openModal = () => { + setIsOpen(true) + } + + return ( + <> +
+ +
+ + + + +
+ + +
+
+ + + +
+ + + + setSearch(e.target.value)} + /> +
+ + {searchResults.length === 0 && searchText !== '' ? ( +
+ No results. +
+ ) : ( + searchResults.map((r) => ( + + {({ selected, active }) => ( + + )} + + )) + )} +
+
+
+
+
+
+
+
+ + ) +} + +export default Search diff --git a/web/package.json b/web/package.json index 92163777..1b2cfa1d 100644 --- a/web/package.json +++ b/web/package.json @@ -22,12 +22,14 @@ "@fontsource/fira-code": "^4.5.11", "@fontsource/inter": "^4.5.12", "@fontsource/source-serif-pro": "^4.5.9", + "@headlessui/react": "^1.7.3", "@mapbox/rehype-prism": "^0.8.0", "@netlify/plugin-nextjs": "4.23", "@tailwindcss/typography": "^0.5.0", "classnames": "^2.3.1", "colord": "^2.9.3", "d3": "^7.6.1", + "fuse.js": "^6.6.2", "gray-matter": "^4.0.2", "inter-ui": "^3.19.3", "next": "^12.3.1", diff --git a/web/pages/config_settings.tsx b/web/pages/config_settings.tsx index 0a5d461e..ba3eaefc 100644 --- a/web/pages/config_settings.tsx +++ b/web/pages/config_settings.tsx @@ -2,14 +2,16 @@ import Layout from '../components/Layout' import SEO from '../components/SEO' import { getConfigSettings, Documentation } from '../utils/doc-utils' import Docs from '../components/Docs' +import { getSearchIndex, SearchIndex } from '../utils/search' export interface ConfigSettingsProps { config_settings: Documentation + searchIndex: SearchIndex } export default function ConfigSettings(props: ConfigSettingsProps) { return ( - + + { const { mdxSource, data } = await getDocumentBySlug(params.slug as string) const prevDocument = getPreviousDocumentBySlug(params.slug as string) const nextDocument = getNextDocumentBySlug(params.slug as string) + const searchIndex = getSearchIndex() return { props: { + searchIndex, source: mdxSource, frontMatter: data, prevDocument, diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 04847ade..64566067 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -3,6 +3,7 @@ import { getDocuments, Document } from '../utils/mdx-utils' import Layout from '../components/Layout' import ArrowIcon from '../components/ArrowIcon' import SEO from '../components/SEO' +import { getSearchIndex, SearchIndex } from '../utils/search' const pages = [ { @@ -24,10 +25,12 @@ const pages = [ interface IndexProps { documents: Document[] + searchIndex: SearchIndex } -export default function Index({ documents }: IndexProps) { + +export default function Index({ documents, searchIndex }: IndexProps) { return ( - +
@@ -76,6 +79,7 @@ export default function Index({ documents }: IndexProps) { export function getStaticProps() { const documents = getDocuments() + const searchIndex = getSearchIndex() - return { props: { documents } } + return { props: { documents, searchIndex } } } diff --git a/web/pages/lua.tsx b/web/pages/lua.tsx index 507a8043..2b84cade 100644 --- a/web/pages/lua.tsx +++ b/web/pages/lua.tsx @@ -2,14 +2,16 @@ import Layout from '../components/Layout' import SEO from '../components/SEO' import { getLua, Documentation } from '../utils/doc-utils' import Docs from '../components/Docs' +import { getSearchIndex, SearchIndex } from '../utils/search' export interface LuaProps { lua: Documentation + searchIndex: SearchIndex } export default function Lua(props: LuaProps) { return ( - +
@@ -23,6 +25,7 @@ export default function Lua(props: LuaProps) { export async function getStaticProps() { const lua = getLua() + const searchIndex = getSearchIndex() - return { props: { lua } } + return { props: { lua, searchIndex } } } diff --git a/web/pages/variables.tsx b/web/pages/variables.tsx index b63ba27c..a5645fbf 100644 --- a/web/pages/variables.tsx +++ b/web/pages/variables.tsx @@ -2,14 +2,16 @@ import Layout from '../components/Layout' import SEO from '../components/SEO' import { getVariables, Documentation } from '../utils/doc-utils' import Docs from '../components/Docs' +import { getSearchIndex, SearchIndex } from '../utils/search' export interface VariablesProps { variables: Documentation + searchIndex: SearchIndex } export default function Variables(props: VariablesProps) { return ( - + ({ ...c, desc: processMarkdown(c.desc) })), + ...parsed, + desc_md: processMarkdown(parsed.desc), + values: parsed.values.map((c) => ({ + ...c, + desc_md: processMarkdown(c.desc), + })), } return docs diff --git a/web/utils/search.ts b/web/utils/search.ts new file mode 100644 index 00000000..80c4c508 --- /dev/null +++ b/web/utils/search.ts @@ -0,0 +1,38 @@ +import Fuse from 'fuse.js' +import { getConfigSettings, getLua, getVariables } from './doc-utils' +export interface SearchItem { + kind: string + name: string + desc: string +} +export interface SearchIndex { + index: { + keys: readonly string[] + records: Fuse.FuseIndexRecords + } + list: SearchItem[] +} + +export function getSearchIndex() { + const cs: SearchItem[] = getConfigSettings().values.map((v) => ({ + kind: 'config', + name: v.name, + desc: v.desc.substring(0, 200), + })) + const vars: SearchItem[] = getVariables().values.map((v) => ({ + kind: 'var', + name: v.name, + desc: v.desc.substring(0, 200), + })) + const lua: SearchItem[] = getLua().values.map((v) => ({ + kind: 'lua', + name: v.name, + desc: v.desc.substring(0, 200), + })) + const list: SearchItem[] = [...cs, ...vars, ...lua] + + return { + list, + index: Fuse.createIndex(['name', 'desc'], list).toJSON(), + } +} diff --git a/web/yarn.lock b/web/yarn.lock index 94a18fd0..a635862d 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -124,6 +124,11 @@ resolved "https://registry.yarnpkg.com/@fontsource/source-serif-pro/-/source-serif-pro-4.5.9.tgz#fbeacd0bb6d2860df6baa662e9827adfe3da13f4" integrity sha512-VgDvUvd3An3v9HtKgYk9TJuhB/4ZXw4huv/uqTXO4gES7CUbqGcf6tSb69TwG6o5AZzEVt6jnN7FN18OaO5J9A== +"@headlessui/react@^1.7.3": + version "1.7.3" + resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.7.3.tgz#853c598ff47b37cdd192c5cbee890d9b610c3ec0" + integrity sha512-LGp06SrGv7BMaIQlTs8s2G06moqkI0cb0b8stgq7KZ3xcHdH3qMP+cRyV7qe5x4XEW/IGY48BW4fLesD6NQLng== + "@humanwhocodes/config-array@^0.10.5": version "0.10.5" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.5.tgz#bb679745224745fff1e9a41961c1d45a49f81c04" @@ -3398,6 +3403,11 @@ functions-have-names@^1.2.2: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +fuse.js@^6.6.2: + version "6.6.2" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111" + integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA== + get-caller-file@^2.0.0, get-caller-file@^2.0.1: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"