Basic search
This commit is contained in:
parent
2552bbc5c8
commit
22385e53f4
@ -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
|
||||
|
@ -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}
|
||||
`,
|
||||
);
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
`,
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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;
|
73
website/src/components/layout/search/Search.tsx
Normal file
73
website/src/components/layout/search/Search.tsx
Normal 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;
|
257
website/src/components/layout/search/SearchModal.tsx
Normal file
257
website/src/components/layout/search/SearchModal.tsx
Normal 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;
|
118
website/src/components/layout/search/SearchResult.tsx
Normal file
118
website/src/components/layout/search/SearchResult.tsx
Normal 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;
|
36
website/src/components/layout/search/SuggestionLink.tsx
Normal file
36
website/src/components/layout/search/SuggestionLink.tsx
Normal 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;
|
@ -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;
|
||||
}
|
||||
|
@ -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()
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -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">
|
||||
|
@ -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: '',
|
||||
|
@ -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>
|
||||
|
@ -26,7 +26,7 @@ const useCreateTheme = (mode: PaletteMode) => {
|
||||
: {
|
||||
mode,
|
||||
primary: {
|
||||
main: '#3A69C7',
|
||||
main: '#5ecffb',
|
||||
},
|
||||
secondary: {
|
||||
main: '#5ecffb',
|
||||
|
89
website/src/util/search.util.ts
Normal file
89
website/src/util/search.util.ts
Normal 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]);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user