Basic search

This commit is contained in:
Daniel Lautzenheiser 2022-11-07 15:21:37 -05:00
parent 2552bbc5c8
commit 22385e53f4
21 changed files with 704 additions and 74 deletions

View File

@ -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', <FontAwesomeIcon icon={faHouse} size="lg" />);
CMS.registerIcon('house', <FontAwesomeIcon icon={faHouse} size="lg" />);
```
## Usage

View File

@ -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}
`,
);

View File

@ -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;
}

View File

@ -50,7 +50,7 @@ const DocsLeftNavGroup = ({ name, links }: DocsLeftNavGroupProps) => {
>
<ListItemText
primaryTypographyProps={{
color: selected ? theme.palette.secondary.main : theme.palette.text.secondary,
color: selected ? theme.palette.primary.main : theme.palette.text.secondary,
fontWeight: selected ? 600 : 400,
}}
primary={link.title}

View File

@ -11,7 +11,7 @@ const AnchorLinkIcon = ({ variant }: AnchorLinkIconProps) => {
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,

View File

@ -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;

View File

@ -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;
}
`,

View File

@ -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) => {
<MenuIcon fontSize="large" />
</StyledMenuButton>
<Logo />
<Search />
<Search searchablePages={searchablePages} />
<StyledIconsWrapper>
<IconButton
sx={{ ml: 1 }}
@ -171,10 +172,7 @@ const Header = ({ mode, docsGroups, toggleColorMode }: HeaderProps) => {
src="https://img.shields.io/github/stars/StaticJsCMS/static-cms?style=social"
/>
</StyledGithubLink>
<IconButton
href="https://github.com/StaticJsCMS/static-cms"
color="inherit"
>
<IconButton href="https://github.com/StaticJsCMS/static-cms" color="inherit">
<GitHubIcon />
</IconButton>
</StyledIconsWrapper>

View File

@ -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<HTMLDivElement | null>(null);
const theme = useTheme();
@ -94,6 +96,7 @@ const Page = ({
<Header
mode={theme.palette.mode}
docsGroups={docsGroups}
searchablePages={searchablePages}
toggleColorMode={colorMode.toggleColorMode}
/>
<StyledPageContentWrapper ref={scrollableArea}>{content}</StyledPageContentWrapper>

View File

@ -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 (
<StyledSearchBox
placeholder="Search the docs"
variant="outlined"
size="small"
InputProps={{
startAdornment: <SearchIcon />,
}}
/>
);
};
export default Search;

View File

@ -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 (
<>
<StyledSearchPlaceholderBox onClick={handleOpen}>
<SearchIcon />
<Typography
variant="body2"
color="inherit"
sx={{
fontFamily: "'Roboto',-apple-system,BlinkMacSystemFont,Helvetica,Arial,sans-serif",
fontWeight: 400,
fontSize: '16px',
opacity: 0.8,
}}
>
Search the docs
</Typography>
</StyledSearchPlaceholderBox>
<SearchModal open={open} onClose={handleClose} searchablePages={searchablePages} />
</>
);
};
export default Search;

View File

@ -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<SearchModalProps> = ({ open, onClose, searchablePages }) => {
const inputRef = useRef<HTMLInputElement>();
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<HTMLInputElement>) => {
setSearch(event.target.value);
}, []);
const searchResults = useSearchScores(search, searchablePages);
const renderedResults = useMemo(
() =>
searchResults?.length > 0 ? (
[...Array<unknown>(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'),
`<strong style="color: ${theme.palette.primary.main}">$1</strong>`,
);
return (
<SearchResult
key={`result-${entry.url}`}
entry={entry}
summary={summary}
onClick={handleClose}
/>
);
})
) : isNotEmpty(search) ? (
<Typography
variant="h3"
component="div"
key="no-results"
sx={{ width: '100%', textAlign: 'center', marginTop: '16px' }}
>
No results found
</Typography>
) : (
<StyledSuggestions>
<StyledSuggestionSection>
<Typography variant="h3" sx={{ marginBottom: '4px' }}>
Getting Started
</Typography>
<SuggestionLink href="/docs/start-with-a-template">
Start With a Template
</SuggestionLink>
<SuggestionLink href="/docs/add-to-your-site">Add to Your Site</SuggestionLink>
<SuggestionLink href="/docs/configuration-options">
Configuration Options
</SuggestionLink>
<SuggestionLink href="/docs/collection-overview">
Collections
</SuggestionLink>
</StyledSuggestionSection>
<StyledSuggestionSection>
<Typography variant="h3" sx={{ marginBottom: '4px' }}>
Backends
</Typography>
<SuggestionLink href="/docs/github-backend">GitHub</SuggestionLink>
<SuggestionLink href="/docs/bitbucket-backend">Bitbucket</SuggestionLink>
<SuggestionLink href="/docs/gitlab-backend">GitLab</SuggestionLink>
</StyledSuggestionSection>
<StyledSuggestionSection>
<Typography variant="h3" sx={{ marginBottom: '4px' }}>
Platform Guides
</Typography>
<SuggestionLink href="/docs/nextjs">Next</SuggestionLink>
<SuggestionLink href="/docs/gatsby">Gatsby</SuggestionLink>
<SuggestionLink href="/docs/jekyll">Jekyll</SuggestionLink>
<SuggestionLink href="/docs/hugo">Hugo</SuggestionLink>
</StyledSuggestionSection>
<StyledSuggestionSection>
<Typography variant="h3" sx={{ marginBottom: '4px' }}>
Widgets
</Typography>
<SuggestionLink href="/docs/widget-string">String</SuggestionLink>
<SuggestionLink href="/docs/widget-image">Image</SuggestionLink>
<SuggestionLink href="/docs/widget-datetime">Datetime</SuggestionLink>
<SuggestionLink href="/docs/widget-markdown">Markdown</SuggestionLink>
</StyledSuggestionSection>
</StyledSuggestions>
),
[handleClose, search, searchResults, theme.palette.primary.main],
);
return (
<StyledDialog
open={open}
onClose={handleClose}
fullScreen={fullScreen}
fullWidth
onFocus={handleFocus}
>
<StyledDialogContent>
<StyledTextField
autoFocus
id="search"
placeholder="Search"
fullWidth
variant="standard"
value={search}
onChange={handleSearchChange}
InputProps={{
inputRef,
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<Button
size="small"
variant="outlined"
color="inherit"
sx={{ p: '0 6px', m: 0, width: 'auto', minWidth: 'unset' }}
onClick={onClose}
>
ESC
</Button>
</InputAdornment>
),
}}
/>
<div>{renderedResults}</div>
</StyledDialogContent>
</StyledDialog>
);
};
export default SearchModal;

View File

@ -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 (
<Link href={url}>
<Button
href={url}
key={`result-${url}`}
onClick={onClick}
// startIcon={<Icon fontSize="large" />}
sx={{
display: 'flex',
alignItems: 'flex-start',
textAlign: 'left',
width: '100%',
color: theme.palette.text.secondary,
lineHeight: 'inherit',
letterSpacing: 'inherit',
textTransform: 'unset',
gap: '8px',
padding: '16px',
boxSize: 'border-box',
borderBottom: '1px solid rgba(255, 255, 255, 0.25)',
'&:hover': {
color: theme.palette.text.primary,
},
'.MuiButton-startIcon': {
marginLeft: 0,
'*:nth-of-type(1)': {
fontSize: '24px',
},
},
}}
>
<StyledSearchResultBody>
<StyledSearchResultTitleWrapper>
<StyledSearchResultTitle>{title}</StyledSearchResultTitle>
</StyledSearchResultTitleWrapper>
<StyledSearchResultContent
dangerouslySetInnerHTML={{
__html: summary,
}}
/>
</StyledSearchResultBody>
</Button>
</Link>
);
};
export default SearchResult;

View File

@ -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<SuggestionLinkProps> = ({ href, children }) => {
return (
<Link href={href}>
<StyledSuggestionLink href={href} color="primary">
{children}
<ChevronRightIcon fontSize="small" sx={{ marginTop: '1px', marginLeft: '2px' }} />
</StyledSuggestionLink>
</Link>
);
};
export default SuggestionLink;

View File

@ -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;
}

View File

@ -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()
},
};
};

View File

@ -78,7 +78,7 @@ const Community = ({ docsGroups }: DocsMenuProps) => {
<StyledCommunityContent>
<Container>
<StyledTitle>
<Typography variant="h1" color="secondary">
<Typography variant="h1" color="primary">
{communityData.title}
</Typography>
<Typography variant="h2" color="text.primary">

View File

@ -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
>
<DocsLeftNav docsGroups={docsGroups} />
@ -160,6 +169,7 @@ export const getStaticProps: GetStaticProps = async ({ params }): Promise<{ prop
return {
props: {
docsGroups,
searchablePages: getSearchablePages(),
title: data.title,
slug: data.slug,
description: '',

View File

@ -256,7 +256,7 @@ const Home = ({ docsGroups }: DocsMenuProps) => {
<StyledIntroSection>
<Container>
<StyledIntroSectionContent>
<Typography variant="h1" color="secondary">
<Typography variant="h1" color="primary">
{homepageData.title}
</Typography>
<Typography variant="h2" color="text.primary">
@ -340,7 +340,7 @@ const Home = ({ docsGroups }: DocsMenuProps) => {
sx={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<>
<Chip label={release.version} color="secondary" />
<Chip label={release.version} color="primary" />
<DateDisplay date={release.date} format="MMMM dd, yyyy" />
</>
</Typography>

View File

@ -26,7 +26,7 @@ const useCreateTheme = (mode: PaletteMode) => {
: {
mode,
primary: {
main: '#3A69C7',
main: '#5ecffb',
},
secondary: {
main: '#5ecffb',

View File

@ -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]);
}