From 22385e53f44a85b076674f088ddd2fc5bacad1e6 Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Mon, 7 Nov 2022 15:21:37 -0500 Subject: [PATCH] Basic search --- website/content/docs/custom-icons.mdx | 3 +- .../components/community/CommunitySection.tsx | 2 +- website/src/components/docs/DocsContent.tsx | 4 +- .../src/components/docs/DocsLeftNavGroup.tsx | 2 +- .../headers/components/AnchorLinkIcon.tsx | 4 +- .../components/headers/hooks/useAnchor.ts | 8 +- .../docs/table_of_contents/DocsHeadings.tsx | 6 +- website/src/components/layout/Header.tsx | 16 +- website/src/components/layout/Page.tsx | 5 +- website/src/components/layout/Search.tsx | 41 --- .../src/components/layout/search/Search.tsx | 73 +++++ .../components/layout/search/SearchModal.tsx | 257 ++++++++++++++++++ .../components/layout/search/SearchResult.tsx | 118 ++++++++ .../layout/search/SuggestionLink.tsx | 36 +++ website/src/interface.ts | 15 +- website/src/lib/docs.ts | 73 ++++- website/src/pages/community.tsx | 2 +- website/src/pages/docs/[doc].tsx | 18 +- website/src/pages/index.tsx | 4 +- website/src/styles/theme.ts | 2 +- website/src/util/search.util.ts | 89 ++++++ 21 files changed, 704 insertions(+), 74 deletions(-) delete mode 100644 website/src/components/layout/Search.tsx create mode 100644 website/src/components/layout/search/Search.tsx create mode 100644 website/src/components/layout/search/SearchModal.tsx create mode 100644 website/src/components/layout/search/SearchResult.tsx create mode 100644 website/src/components/layout/search/SuggestionLink.tsx create mode 100644 website/src/util/search.util.ts diff --git a/website/content/docs/custom-icons.mdx b/website/content/docs/custom-icons.mdx index a9ad352f..050b8395 100644 --- a/website/content/docs/custom-icons.mdx +++ b/website/content/docs/custom-icons.mdx @@ -22,8 +22,9 @@ This example uses Font Awesome to supply the icon. ```js import { faHouse } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import CMS from '@staticcms/core'; -cmsApp.registerIcon('house', ); +CMS.registerIcon('house', ); ``` ## Usage diff --git a/website/src/components/community/CommunitySection.tsx b/website/src/components/community/CommunitySection.tsx index 7d7ab348..b5dd63eb 100644 --- a/website/src/components/community/CommunitySection.tsx +++ b/website/src/components/community/CommunitySection.tsx @@ -12,7 +12,7 @@ const StyledCommunitySection = styled('div')( display: flex; flex-direction: column; gap: 16px; - color: ${theme.palette.secondary.main} + color: ${theme.palette.primary.main} `, ); diff --git a/website/src/components/docs/DocsContent.tsx b/website/src/components/docs/DocsContent.tsx index d5809025..e4fa5684 100644 --- a/website/src/components/docs/DocsContent.tsx +++ b/website/src/components/docs/DocsContent.tsx @@ -38,7 +38,7 @@ const DocsContent = styled('div')( } & :not(h1,h2,h3,h4,h5,h6) a { - color: ${theme.palette.secondary.main}; + color: ${theme.palette.primary.main}; text-decoration: none; font-weight: 500; } @@ -93,7 +93,7 @@ const DocsContent = styled('div')( } & h2 { - color: ${theme.palette.secondary.main}; + color: ${theme.palette.primary.main}; font-size: 26px; line-height: 26px; } diff --git a/website/src/components/docs/DocsLeftNavGroup.tsx b/website/src/components/docs/DocsLeftNavGroup.tsx index 54e8e061..eb2d23ec 100644 --- a/website/src/components/docs/DocsLeftNavGroup.tsx +++ b/website/src/components/docs/DocsLeftNavGroup.tsx @@ -50,7 +50,7 @@ const DocsLeftNavGroup = ({ name, links }: DocsLeftNavGroupProps) => { > { const color = useMemo(() => { if (variant === 'h2') { - return theme.palette.secondary.main; + return theme.palette.primary.main; } if (variant === 'h3') { @@ -20,7 +20,7 @@ const AnchorLinkIcon = ({ variant }: AnchorLinkIconProps) => { return theme.palette.text.secondary; }, [ - theme.palette.secondary.main, + theme.palette.primary.main, theme.palette.text.primary, theme.palette.text.secondary, variant, diff --git a/website/src/components/docs/components/headers/hooks/useAnchor.ts b/website/src/components/docs/components/headers/hooks/useAnchor.ts index 5de18c20..df552609 100644 --- a/website/src/components/docs/components/headers/hooks/useAnchor.ts +++ b/website/src/components/docs/components/headers/hooks/useAnchor.ts @@ -1,4 +1,6 @@ -const useAnchor = (text: string) => { +import { useMemo } from 'react'; + +export const getAnchor = (text: string) => { return text .trim() .toLowerCase() @@ -6,4 +8,8 @@ const useAnchor = (text: string) => { .replace(/[ ]/g, '-'); }; +const useAnchor = (text: string) => { + return useMemo(() => getAnchor(text), [text]); +}; + export default useAnchor; diff --git a/website/src/components/docs/table_of_contents/DocsHeadings.tsx b/website/src/components/docs/table_of_contents/DocsHeadings.tsx index c8dba76d..cfbc72cb 100644 --- a/website/src/components/docs/table_of_contents/DocsHeadings.tsx +++ b/website/src/components/docs/table_of_contents/DocsHeadings.tsx @@ -30,12 +30,12 @@ const StyledListItem = styled('li')( } &.active > a { - color: ${theme.palette.secondary.main}; - border-left: 2px solid ${theme.palette.secondary.main}; + color: ${theme.palette.primary.main}; + border-left: 2px solid ${theme.palette.primary.main}; } & > a:hover { - color: ${theme.palette.secondary.main}; + color: ${theme.palette.primary.main}; text-decoration: underline; } `, diff --git a/website/src/components/layout/Header.tsx b/website/src/components/layout/Header.tsx index a26b69d2..da1d0a51 100644 --- a/website/src/components/layout/Header.tsx +++ b/website/src/components/layout/Header.tsx @@ -1,5 +1,6 @@ import Brightness4Icon from '@mui/icons-material/Brightness4'; import Brightness7Icon from '@mui/icons-material/Brightness7'; +import GitHubIcon from '@mui/icons-material/GitHub'; import MenuIcon from '@mui/icons-material/Menu'; import AppBar from '@mui/material/AppBar'; import Button from '@mui/material/Button'; @@ -7,17 +8,16 @@ import IconButton from '@mui/material/IconButton'; import { styled } from '@mui/material/styles'; import Toolbar from '@mui/material/Toolbar'; import Link from 'next/link'; -import GitHubIcon from '@mui/icons-material/GitHub'; import { useCallback, useMemo, useState } from 'react'; import Logo from './Logo'; import NavigationDrawer from './mobile-drawer/NavigationDrawer'; -import Search from './Search'; +import Search from './search/Search'; import type { PaletteMode } from '@mui/material'; import type { ButtonTypeMap } from '@mui/material/Button'; import type { ExtendButtonBase } from '@mui/material/ButtonBase'; -import type { DocsGroup, MenuItem } from '../../interface'; +import type { DocsGroup, MenuItem, SearchablePage } from '../../interface'; const StyledAppBar = styled(AppBar)( ({ theme }) => ` @@ -101,10 +101,11 @@ const StyledDesktopLink = styled(Button)( interface HeaderProps { mode: PaletteMode; docsGroups: DocsGroup[]; + searchablePages: SearchablePage[]; toggleColorMode: () => void; } -const Header = ({ mode, docsGroups, toggleColorMode }: HeaderProps) => { +const Header = ({ mode, docsGroups, searchablePages, toggleColorMode }: HeaderProps) => { const [mobileOpen, setMobileOpen] = useState(false); const handleDrawerToggle = useCallback(() => { @@ -150,7 +151,7 @@ const Header = ({ mode, docsGroups, toggleColorMode }: HeaderProps) => { - + { src="https://img.shields.io/github/stars/StaticJsCMS/static-cms?style=social" /> - + diff --git a/website/src/components/layout/Page.tsx b/website/src/components/layout/Page.tsx index c1a05871..311b7bb0 100644 --- a/website/src/components/layout/Page.tsx +++ b/website/src/components/layout/Page.tsx @@ -12,7 +12,7 @@ import Container from './Container'; import Header from './Header'; import type { ReactNode } from 'react'; -import type { DocsGroup } from '../../interface'; +import type { DocsGroup, SearchablePage } from '../../interface'; const StyledPageContentWrapper = styled('div')` display: block; @@ -36,6 +36,7 @@ export interface PageProps { }; fullWidth?: boolean; docsGroups: DocsGroup[]; + searchablePages: SearchablePage[]; } const Page = ({ @@ -47,6 +48,7 @@ const Page = ({ pageDetails, fullWidth = false, docsGroups, + searchablePages, }: PageProps) => { const scrollableArea = useRef(null); const theme = useTheme(); @@ -94,6 +96,7 @@ const Page = ({
{content} diff --git a/website/src/components/layout/Search.tsx b/website/src/components/layout/Search.tsx deleted file mode 100644 index c2269ccf..00000000 --- a/website/src/components/layout/Search.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import SearchIcon from '@mui/icons-material/Search'; -import { styled } from '@mui/material/styles'; -import TextField from '@mui/material/TextField'; - -const StyledSearchBox = styled(TextField)( - ({ theme }) => ` - background-color: rgba(255, 255, 255, 0.1); - border-radius: 4px; - - .MuiSvgIcon-root { - color: rgba(255, 255, 255, 0.8); - } - - .MuiInputBase-root { - color: white; - } - - .MuiOutlinedInput-notchedOutline { - border: none; - } - - ${theme.breakpoints.down('lg')} { - display: none; - } - `, -); - -const Search = () => { - return ( - , - }} - /> - ); -}; - -export default Search; diff --git a/website/src/components/layout/search/Search.tsx b/website/src/components/layout/search/Search.tsx new file mode 100644 index 00000000..84cb64fa --- /dev/null +++ b/website/src/components/layout/search/Search.tsx @@ -0,0 +1,73 @@ +import SearchIcon from '@mui/icons-material/Search'; +import Button from '@mui/material/Button'; +import { styled } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; +import { useCallback, useState } from 'react'; + +import SearchModal from './SearchModal'; + +import type { SearchablePage } from '../../../interface'; + +const StyledSearchPlaceholderBox = styled(Button)( + ({ theme }) => ` + background-color: rgba(255, 255, 255, 0.1); + border-radius: 4px; + width: 244px; + height: 40px; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + padding: 0 12px; + color: inherit; + text-transform: none; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + color: inherit; + } + + ${theme.breakpoints.down('lg')} { + display: none; + } + `, +); + +export interface SearchProps { + searchablePages: SearchablePage[]; +} + +const Search = ({ searchablePages }: SearchProps) => { + const [open, setOpen] = useState(false); + + const handleOpen = useCallback(() => { + setOpen(true); + }, []); + + const handleClose = useCallback(() => { + setOpen(false); + }, []); + + return ( + <> + + + + Search the docs + + + + + ); +}; + +export default Search; diff --git a/website/src/components/layout/search/SearchModal.tsx b/website/src/components/layout/search/SearchModal.tsx new file mode 100644 index 00000000..7ee7f51d --- /dev/null +++ b/website/src/components/layout/search/SearchModal.tsx @@ -0,0 +1,257 @@ +import SearchIcon from '@mui/icons-material/Search'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import InputAdornment from '@mui/material/InputAdornment'; +import { styled, useTheme } from '@mui/material/styles'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { useCallback, useMemo, useRef, useState } from 'react'; + +import { useSearchScores } from '../../../util/search.util'; +import { isNotEmpty } from '../../../util/string.util'; +import SearchResult from './SearchResult'; +import SuggestionLink from './SuggestionLink'; + +import type { ChangeEvent, FC } from 'react'; +import type { SearchablePage } from '../../../interface'; + +const SEARCH_RESULTS_TO_SHOW = 5; + +const StyledDialog = styled(Dialog)( + ({ theme }) => ` + ${theme.breakpoints.between('md', 'lg')} { + & .MuiDialog-paper { + width: 60vw; + height: 60vh; + maxHeight: 600px; + } + } + + ${theme.breakpoints.up('lg')} { + & .MuiDialog-paper { + width: 600px; + height: 600px; + } + } + `, +); + +const StyledDialogContent = styled(DialogContent)` + padding: 0; +`; + +const StyledTextField = styled(TextField)` + height: 68px; + + & .MuiInputBase-root { + padding: 16px; + } + + & .MuiInputBase-root.MuiInput-root::after { + border-bottom: unset; + } + + & .MuiInput-root { + font-size: 18px; + } +`; + +const StyledSuggestions = styled('div')` + display: grid; + gap: 48px; + padding: 24px; + grid-template-columns: repeat(2, minmax(0, 2fr)); + grid-template-rows: repeat(2, minmax(0, 2fr)); +`; + +const StyledSuggestionSection = styled('div')` + display: flex; + flex-direction: column; + gap: 4px; +`; + +interface SearchModalProps { + open: boolean; + onClose: () => void; + searchablePages: SearchablePage[]; +} + +const SearchModal: FC = ({ open, onClose, searchablePages }) => { + const inputRef = useRef(); + + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('md')); + + const [canFocus, setCanFocus] = useState(true); + const [search, setSearch] = useState(''); + + const handleFocus = () => { + if (canFocus && open && inputRef.current) { + inputRef.current?.focus(); + setSearch(''); + setCanFocus(false); + } + }; + + const handleClose = useCallback(() => { + setCanFocus(true); + onClose(); + }, [onClose]); + + const handleSearchChange = useCallback((event: ChangeEvent) => { + setSearch(event.target.value); + }, []); + + const searchResults = useSearchScores(search, searchablePages); + + const renderedResults = useMemo( + () => + searchResults?.length > 0 ? ( + [...Array(SEARCH_RESULTS_TO_SHOW)].map((_, index) => { + if (searchResults.length <= index) { + return; + } + + const result = searchResults[index]; + const { entry } = result; + let summary = entry.textContent; + + if (!result.isExactTitleMatch) { + const match = new RegExp( + `(?:[\\s]+[^\\s]+){0,10}[\\s]*${search}(?![^<>]*(([/"']|]]|\b)>))[\\s]*(?:[^\\s]+\\s){0,25}`, + 'ig', + ).exec(entry.textContent); + if (match && match.length >= 1) { + summary = `...${match[0].trim()}...`; + } else { + const match = new RegExp( + `(?:[\\s]+[^\\s]+){0,10}[\\s]*(${search + .split(' ') + .join('|')})(?![^<>]*(([/"']|]]|\b)>))[\\s]*(?:[^\\s]+\\s){0,25}`, + 'ig', + ).exec(entry.textContent); + if (match && match.length >= 1) { + summary = `...${match[0].trim()}...`; + } + } + } + + summary = summary?.replace( + new RegExp(`(${search.split(' ').join('|')})(?![^<>]*(([/"']|]]|\b)>))`, 'ig'), + `$1`, + ); + + return ( + + ); + }) + ) : isNotEmpty(search) ? ( + + No results found + + ) : ( + + + + Getting Started + + + Start With a Template + + Add to Your Site + + Configuration Options + + + Collections + + + + + Backends + + GitHub + Bitbucket + GitLab + + + + Platform Guides + + Next + Gatsby + Jekyll + Hugo + + + + Widgets + + String + Image + Datetime + Markdown + + + ), + [handleClose, search, searchResults, theme.palette.primary.main], + ); + + return ( + + + + + + ), + endAdornment: ( + + + + ), + }} + /> +
{renderedResults}
+
+
+ ); +}; + +export default SearchModal; diff --git a/website/src/components/layout/search/SearchResult.tsx b/website/src/components/layout/search/SearchResult.tsx new file mode 100644 index 00000000..887c0ed2 --- /dev/null +++ b/website/src/components/layout/search/SearchResult.tsx @@ -0,0 +1,118 @@ +import Button from '@mui/material/Button'; +import { styled, useTheme } from '@mui/material/styles'; +import Link from 'next/link'; + +import type { SearchablePage } from '../../../interface'; + +const StyledSearchResultBody = styled('div')` + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + box-sizing: border-box; + gap: 8px; + overflow: hidden; +`; + +const StyledSearchResultTitleWrapper = styled('div')` + display: flex; + align-items: baseline; + gap: 8px; +`; + +const StyledSearchResultTitle = styled('h3')( + ({ theme }) => ` + margin: 0; + font-size: 18px; + line-height: 22px; + color: ${theme.palette.primary.main}; + `, +); + +const StyledSearchResultContent = styled('div')( + ({ theme }) => ` + margin: 0; + font-size: 16px; + line-height: 1.25; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + color: ${theme.palette.text.secondary}; + + h2, + h3, + h4, + h5, + h6 { + margin-top: 4px; + } + `, +); + +interface SearchResultProps { + entry: SearchablePage; + summary: string; + onClick: () => void; +} + +const SearchResult = ({ entry: { url, title }, summary, onClick }: SearchResultProps) => { + const theme = useTheme(); + + // const Icon = useMemo(() => { + // switch (type) { + // case NEWS: + // return ArticleIcon; + // case BULLETIN: + // return NewspaperIcon; + // default: + // return WebIcon; + // } + // }, [type]); + + return ( + + + + ); +}; + +export default SearchResult; diff --git a/website/src/components/layout/search/SuggestionLink.tsx b/website/src/components/layout/search/SuggestionLink.tsx new file mode 100644 index 00000000..19609ee4 --- /dev/null +++ b/website/src/components/layout/search/SuggestionLink.tsx @@ -0,0 +1,36 @@ +import MuiLink from '@mui/material/Link'; +import { styled } from '@mui/material/styles'; +import Link from 'next/link'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; + +import type { FC } from 'react'; + +const StyledSuggestionLink = styled(MuiLink)` + text-decoration: none; + display: flex; + padding-left: 8px; + transition: gap .1s ease-in-out; + gap: 0; + &:hover { + text-decoration: underline; + gap: 4px; + } +`; + +interface SuggestionLinkProps { + href: string; + children: string; +} + +const SuggestionLink: FC = ({ href, children }) => { + return ( + + + {children} + + + + ); +}; + +export default SuggestionLink; diff --git a/website/src/interface.ts b/website/src/interface.ts index 548faa7b..64554d1c 100644 --- a/website/src/interface.ts +++ b/website/src/interface.ts @@ -76,9 +76,15 @@ export interface DocsData { readonly slug: string; } +export interface DocsPageHeading { + readonly title: string; + readonly anchor: string; +} + export interface DocsPage { readonly fullPath: string; - readonly summary: string; + readonly headings: DocsPageHeading[]; + readonly textContent: string; readonly content: string; readonly data: DocsData; } @@ -137,3 +143,10 @@ export interface MenuLinkGroup { } export type MenuItem = MenuLinkGroup | MenuLink; + +export interface SearchablePage { + readonly title: string; + readonly url: string; + readonly headings: DocsPageHeading[]; + readonly textContent: string; +} diff --git a/website/src/lib/docs.ts b/website/src/lib/docs.ts index 33913f1f..10b9bb1f 100644 --- a/website/src/lib/docs.ts +++ b/website/src/lib/docs.ts @@ -3,13 +3,25 @@ import matter from 'gray-matter'; import yaml from 'js-yaml'; import path from 'path'; +import { getAnchor } from '../components/docs/components/headers/hooks/useAnchor'; import { SUMMARY_MIN_PARAGRAPH_LENGTH } from '../constants'; import menu from './menu'; import type { GetStaticProps } from 'next'; -import type { DocsData, DocsGroup, DocsGroupLink, DocsPage, FileMatter } from '../interface'; +import type { + DocsData, + DocsGroup, + DocsGroupLink, + DocsPage, + FileMatter, + SearchablePage, +} from '../interface'; -export interface DocsMenuProps { +export interface SearchProps { + searchablePages: SearchablePage[]; +} + +export interface DocsMenuProps extends SearchProps { docsGroups: DocsGroup[]; } @@ -48,6 +60,37 @@ export function fetchDocsMatter(): FileMatter[] { return docsMatterCache; } +function getHeadings(content: string): string[] { + const headingRegex = /^## ([^\n]+)/gm; + let matches = headingRegex.exec(content); + + const headings: string[] = []; + while (matches && matches.length === 2) { + headings.push(matches[1]); + matches = headingRegex.exec(content); + } + + return headings; +} + +function getTextContent(content: string): string { + const textContentRegex = + /^(?:-|\*|\n)((?!```|<| |const|interface|export|import|let|var|CMS\.)(?:[`\-# {*]*)[a-zA-Z]+[^|\n]+)$/gm; + let matches = textContentRegex.exec(content); + + const paragraphs: string[] = []; + while (matches && matches.length === 2) { + paragraphs.push( + matches[1] + .replace(/(^- )|(`)|(^[#]+ )|(\*\*)|((?<= )_)|(^_)|(_(?=[ .]{1}))|(_$)/gm, '') + .replace(/\[([^\]]+)\]\((?:[^)]+)\)/gm, '$1'), + ); + matches = textContentRegex.exec(content); + } + + return paragraphs.join('\n'); +} + export function fetchDocsContent(): [DocsPage[], DocsGroup[]] { if (docsCache && process.env.NODE_ENV !== 'development') { return docsCache; @@ -76,7 +119,11 @@ export function fetchDocsContent(): [DocsPage[], DocsGroup[]] { ...data, slug, } as DocsData, - summary: summaryMatch && summaryMatch.length >= 2 ? summaryMatch[1] : content, + textContent: getTextContent(content), + headings: getHeadings(content).map(heading => ({ + title: heading, + anchor: getAnchor(heading), + })), content, }; }, @@ -103,10 +150,30 @@ export function fetchDocsContent(): [DocsPage[], DocsGroup[]] { return docsCache; } +export function getSearchablePages(): SearchablePage[] { + const pages = fetchDocsContent()[0]; + + return pages.map(page => ({ + title: page.data.title, + textContent: page.textContent, + url: `/docs/${page.data.slug}`, + headings: page.headings, + })); +} + +export const getSearchStaticProps: GetStaticProps = (): { props: SearchProps } => { + return { + props: { + searchablePages: getSearchablePages(), + }, + }; +}; + export const getDocsMenuStaticProps: GetStaticProps = (): { props: DocsMenuProps } => { return { props: { docsGroups: fetchDocsContent()[1], + searchablePages: getSearchablePages() }, }; }; diff --git a/website/src/pages/community.tsx b/website/src/pages/community.tsx index 3f13393c..9a3c66f5 100644 --- a/website/src/pages/community.tsx +++ b/website/src/pages/community.tsx @@ -78,7 +78,7 @@ const Community = ({ docsGroups }: DocsMenuProps) => { - + {communityData.title} diff --git a/website/src/pages/docs/[doc].tsx b/website/src/pages/docs/[doc].tsx index 0c43fa30..6b4e1451 100644 --- a/website/src/pages/docs/[doc].tsx +++ b/website/src/pages/docs/[doc].tsx @@ -1,6 +1,6 @@ +import Alert from '@mui/material/Alert'; import { styled, useTheme } from '@mui/material/styles'; import Typography from '@mui/material/Typography'; -import Alert from '@mui/material/Alert'; import { MDXRemote } from 'next-mdx-remote'; import { serialize } from 'next-mdx-remote/serialize'; import remarkGfm from 'remark-gfm'; @@ -22,11 +22,11 @@ import DocsContent from '../../components/docs/DocsContent'; import DocsLeftNav from '../../components/docs/DocsLeftNav'; import DocsRightNav from '../../components/docs/DocsRightNav'; import Page from '../../components/layout/Page'; -import { fetchDocsContent } from '../../lib/docs'; +import { fetchDocsContent, getSearchablePages } from '../../lib/docs'; import type { MDXRemoteSerializeResult } from 'next-mdx-remote'; import type { GetStaticPaths, GetStaticProps } from 'next/types'; -import type { DocsGroup, DocsPage } from '../../interface'; +import type { DocsGroup, DocsPage, SearchablePage } from '../../interface'; const StyledDocsView = styled('div')( ({ theme }) => ` @@ -74,13 +74,21 @@ const StyledDocsContentWrapper = styled('main')( interface DocsProps { docsGroups: DocsGroup[]; + searchablePages: SearchablePage[]; title: string; slug: string; description?: string; source: MDXRemoteSerializeResult; } -const Docs = ({ docsGroups, title, slug, description = '', source }: DocsProps) => { +const Docs = ({ + docsGroups, + searchablePages, + title, + slug, + description = '', + source, +}: DocsProps) => { const theme = useTheme(); return ( @@ -89,6 +97,7 @@ const Docs = ({ docsGroups, title, slug, description = '', source }: DocsProps) url={`/docs/${slug}`} description={description} docsGroups={docsGroups} + searchablePages={searchablePages} fullWidth > @@ -160,6 +169,7 @@ export const getStaticProps: GetStaticProps = async ({ params }): Promise<{ prop return { props: { docsGroups, + searchablePages: getSearchablePages(), title: data.title, slug: data.slug, description: '', diff --git a/website/src/pages/index.tsx b/website/src/pages/index.tsx index cffc756f..1910ec66 100644 --- a/website/src/pages/index.tsx +++ b/website/src/pages/index.tsx @@ -256,7 +256,7 @@ const Home = ({ docsGroups }: DocsMenuProps) => { - + {homepageData.title} @@ -340,7 +340,7 @@ const Home = ({ docsGroups }: DocsMenuProps) => { sx={{ display: 'flex', alignItems: 'center', gap: '8px' }} > <> - + diff --git a/website/src/styles/theme.ts b/website/src/styles/theme.ts index 44947f1d..972c0235 100644 --- a/website/src/styles/theme.ts +++ b/website/src/styles/theme.ts @@ -26,7 +26,7 @@ const useCreateTheme = (mode: PaletteMode) => { : { mode, primary: { - main: '#3A69C7', + main: '#5ecffb', }, secondary: { main: '#5ecffb', diff --git a/website/src/util/search.util.ts b/website/src/util/search.util.ts new file mode 100644 index 00000000..68d1883a --- /dev/null +++ b/website/src/util/search.util.ts @@ -0,0 +1,89 @@ +import { useMemo } from 'react'; + +import { isEmpty } from './string.util'; + +import type { DocsPageHeading, SearchablePage } from '../interface'; + +const PARTIAL_MATCH_WORD_LENGTH_THRESHOLD = 5; +const WHOLE_WORD_MATCH_FAVOR_WEIGHT = 2; +const TITLE_FAVOR_WEIGHT = 15; + +export interface SearchScore { + entry: SearchablePage; + metaScore: number; + score: number; + isExactTitleMatch: boolean; + matchedHeader: DocsPageHeading | undefined; +} + +function getSearchScore(words: string[], entry: SearchablePage): SearchScore { + let score = 0; + let metaScore = 0; + + for (const word of words) { + score += + (entry.title.match(new RegExp(`\\b${word}\\b`, 'gi')) ?? []).length * + TITLE_FAVOR_WEIGHT * + WHOLE_WORD_MATCH_FAVOR_WEIGHT; + score += + (entry.textContent.match(new RegExp(`\\b${word}\\b`, 'gi')) ?? []).length * + WHOLE_WORD_MATCH_FAVOR_WEIGHT; + + if (word.length >= PARTIAL_MATCH_WORD_LENGTH_THRESHOLD) { + score += (entry.title.match(new RegExp(`${word}`, 'gi')) ?? []).length * TITLE_FAVOR_WEIGHT; + score += (entry.textContent.match(new RegExp(`${word}`, 'gi')) ?? []).length; + } + } + + const exactMatchFavorWeight = words.length; + const exactSearch = words.join(' ').toLowerCase(); + const isExactTitleMatch = entry.title.toLowerCase().includes(exactSearch); + + const exactTitleMatchScore = + (isExactTitleMatch ? 1 : 0) * + TITLE_FAVOR_WEIGHT * + exactMatchFavorWeight * + WHOLE_WORD_MATCH_FAVOR_WEIGHT; + + if (isExactTitleMatch) { + metaScore += 1; + } + + score += exactTitleMatchScore; + score += + (entry.textContent.match(new RegExp(`\\b${exactSearch}\\b`, 'gi')) ?? []).length * + exactMatchFavorWeight * + WHOLE_WORD_MATCH_FAVOR_WEIGHT; + + return { + score, + metaScore, + entry, + isExactTitleMatch: exactTitleMatchScore > 0, + matchedHeader: entry.headings.find(header => header.title.toLowerCase().includes(exactSearch)), + }; +} + +export function useSearchScores(query: string | null, entries: SearchablePage[]): SearchScore[] { + return useMemo(() => { + if (!query || isEmpty(query.trim())) { + return []; + } + + const queryWords = query.split(' ').filter(word => word.trim().length > 0); + + const scores = entries + .map(entry => getSearchScore(queryWords, entry)) + .filter(result => result.score > 0); + + scores.sort((a, b) => { + if (a.metaScore !== b.metaScore) { + return b.metaScore - a.metaScore; + } + + return b.score - a.score; + }); + + return scores; + }, [entries, query]); +}