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
|
```js
|
||||||
import { faHouse } from '@fortawesome/free-solid-svg-icons';
|
import { faHouse } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
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
|
## Usage
|
||||||
|
@ -12,7 +12,7 @@ const StyledCommunitySection = styled('div')(
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
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 {
|
& :not(h1,h2,h3,h4,h5,h6) a {
|
||||||
color: ${theme.palette.secondary.main};
|
color: ${theme.palette.primary.main};
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@ -93,7 +93,7 @@ const DocsContent = styled('div')(
|
|||||||
}
|
}
|
||||||
|
|
||||||
& h2 {
|
& h2 {
|
||||||
color: ${theme.palette.secondary.main};
|
color: ${theme.palette.primary.main};
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
line-height: 26px;
|
line-height: 26px;
|
||||||
}
|
}
|
||||||
|
@ -50,7 +50,7 @@ const DocsLeftNavGroup = ({ name, links }: DocsLeftNavGroupProps) => {
|
|||||||
>
|
>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primaryTypographyProps={{
|
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,
|
fontWeight: selected ? 600 : 400,
|
||||||
}}
|
}}
|
||||||
primary={link.title}
|
primary={link.title}
|
||||||
|
@ -11,7 +11,7 @@ const AnchorLinkIcon = ({ variant }: AnchorLinkIconProps) => {
|
|||||||
|
|
||||||
const color = useMemo(() => {
|
const color = useMemo(() => {
|
||||||
if (variant === 'h2') {
|
if (variant === 'h2') {
|
||||||
return theme.palette.secondary.main;
|
return theme.palette.primary.main;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (variant === 'h3') {
|
if (variant === 'h3') {
|
||||||
@ -20,7 +20,7 @@ const AnchorLinkIcon = ({ variant }: AnchorLinkIconProps) => {
|
|||||||
|
|
||||||
return theme.palette.text.secondary;
|
return theme.palette.text.secondary;
|
||||||
}, [
|
}, [
|
||||||
theme.palette.secondary.main,
|
theme.palette.primary.main,
|
||||||
theme.palette.text.primary,
|
theme.palette.text.primary,
|
||||||
theme.palette.text.secondary,
|
theme.palette.text.secondary,
|
||||||
variant,
|
variant,
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
const useAnchor = (text: string) => {
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export const getAnchor = (text: string) => {
|
||||||
return text
|
return text
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@ -6,4 +8,8 @@ const useAnchor = (text: string) => {
|
|||||||
.replace(/[ ]/g, '-');
|
.replace(/[ ]/g, '-');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const useAnchor = (text: string) => {
|
||||||
|
return useMemo(() => getAnchor(text), [text]);
|
||||||
|
};
|
||||||
|
|
||||||
export default useAnchor;
|
export default useAnchor;
|
||||||
|
@ -30,12 +30,12 @@ const StyledListItem = styled('li')(
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.active > a {
|
&.active > a {
|
||||||
color: ${theme.palette.secondary.main};
|
color: ${theme.palette.primary.main};
|
||||||
border-left: 2px solid ${theme.palette.secondary.main};
|
border-left: 2px solid ${theme.palette.primary.main};
|
||||||
}
|
}
|
||||||
|
|
||||||
& > a:hover {
|
& > a:hover {
|
||||||
color: ${theme.palette.secondary.main};
|
color: ${theme.palette.primary.main};
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import Brightness4Icon from '@mui/icons-material/Brightness4';
|
import Brightness4Icon from '@mui/icons-material/Brightness4';
|
||||||
import Brightness7Icon from '@mui/icons-material/Brightness7';
|
import Brightness7Icon from '@mui/icons-material/Brightness7';
|
||||||
|
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||||
import MenuIcon from '@mui/icons-material/Menu';
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
import AppBar from '@mui/material/AppBar';
|
import AppBar from '@mui/material/AppBar';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
@ -7,17 +8,16 @@ import IconButton from '@mui/material/IconButton';
|
|||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import Toolbar from '@mui/material/Toolbar';
|
import Toolbar from '@mui/material/Toolbar';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import Logo from './Logo';
|
import Logo from './Logo';
|
||||||
import NavigationDrawer from './mobile-drawer/NavigationDrawer';
|
import NavigationDrawer from './mobile-drawer/NavigationDrawer';
|
||||||
import Search from './Search';
|
import Search from './search/Search';
|
||||||
|
|
||||||
import type { PaletteMode } from '@mui/material';
|
import type { PaletteMode } from '@mui/material';
|
||||||
import type { ButtonTypeMap } from '@mui/material/Button';
|
import type { ButtonTypeMap } from '@mui/material/Button';
|
||||||
import type { ExtendButtonBase } from '@mui/material/ButtonBase';
|
import type { ExtendButtonBase } from '@mui/material/ButtonBase';
|
||||||
import type { DocsGroup, MenuItem } from '../../interface';
|
import type { DocsGroup, MenuItem, SearchablePage } from '../../interface';
|
||||||
|
|
||||||
const StyledAppBar = styled(AppBar)(
|
const StyledAppBar = styled(AppBar)(
|
||||||
({ theme }) => `
|
({ theme }) => `
|
||||||
@ -101,10 +101,11 @@ const StyledDesktopLink = styled(Button)(
|
|||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
mode: PaletteMode;
|
mode: PaletteMode;
|
||||||
docsGroups: DocsGroup[];
|
docsGroups: DocsGroup[];
|
||||||
|
searchablePages: SearchablePage[];
|
||||||
toggleColorMode: () => void;
|
toggleColorMode: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header = ({ mode, docsGroups, toggleColorMode }: HeaderProps) => {
|
const Header = ({ mode, docsGroups, searchablePages, toggleColorMode }: HeaderProps) => {
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
const handleDrawerToggle = useCallback(() => {
|
const handleDrawerToggle = useCallback(() => {
|
||||||
@ -150,7 +151,7 @@ const Header = ({ mode, docsGroups, toggleColorMode }: HeaderProps) => {
|
|||||||
<MenuIcon fontSize="large" />
|
<MenuIcon fontSize="large" />
|
||||||
</StyledMenuButton>
|
</StyledMenuButton>
|
||||||
<Logo />
|
<Logo />
|
||||||
<Search />
|
<Search searchablePages={searchablePages} />
|
||||||
<StyledIconsWrapper>
|
<StyledIconsWrapper>
|
||||||
<IconButton
|
<IconButton
|
||||||
sx={{ ml: 1 }}
|
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"
|
src="https://img.shields.io/github/stars/StaticJsCMS/static-cms?style=social"
|
||||||
/>
|
/>
|
||||||
</StyledGithubLink>
|
</StyledGithubLink>
|
||||||
<IconButton
|
<IconButton href="https://github.com/StaticJsCMS/static-cms" color="inherit">
|
||||||
href="https://github.com/StaticJsCMS/static-cms"
|
|
||||||
color="inherit"
|
|
||||||
>
|
|
||||||
<GitHubIcon />
|
<GitHubIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</StyledIconsWrapper>
|
</StyledIconsWrapper>
|
||||||
|
@ -12,7 +12,7 @@ import Container from './Container';
|
|||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { DocsGroup } from '../../interface';
|
import type { DocsGroup, SearchablePage } from '../../interface';
|
||||||
|
|
||||||
const StyledPageContentWrapper = styled('div')`
|
const StyledPageContentWrapper = styled('div')`
|
||||||
display: block;
|
display: block;
|
||||||
@ -36,6 +36,7 @@ export interface PageProps {
|
|||||||
};
|
};
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
docsGroups: DocsGroup[];
|
docsGroups: DocsGroup[];
|
||||||
|
searchablePages: SearchablePage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Page = ({
|
const Page = ({
|
||||||
@ -47,6 +48,7 @@ const Page = ({
|
|||||||
pageDetails,
|
pageDetails,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
docsGroups,
|
docsGroups,
|
||||||
|
searchablePages,
|
||||||
}: PageProps) => {
|
}: PageProps) => {
|
||||||
const scrollableArea = useRef<HTMLDivElement | null>(null);
|
const scrollableArea = useRef<HTMLDivElement | null>(null);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@ -94,6 +96,7 @@ const Page = ({
|
|||||||
<Header
|
<Header
|
||||||
mode={theme.palette.mode}
|
mode={theme.palette.mode}
|
||||||
docsGroups={docsGroups}
|
docsGroups={docsGroups}
|
||||||
|
searchablePages={searchablePages}
|
||||||
toggleColorMode={colorMode.toggleColorMode}
|
toggleColorMode={colorMode.toggleColorMode}
|
||||||
/>
|
/>
|
||||||
<StyledPageContentWrapper ref={scrollableArea}>{content}</StyledPageContentWrapper>
|
<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;
|
readonly slug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DocsPageHeading {
|
||||||
|
readonly title: string;
|
||||||
|
readonly anchor: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DocsPage {
|
export interface DocsPage {
|
||||||
readonly fullPath: string;
|
readonly fullPath: string;
|
||||||
readonly summary: string;
|
readonly headings: DocsPageHeading[];
|
||||||
|
readonly textContent: string;
|
||||||
readonly content: string;
|
readonly content: string;
|
||||||
readonly data: DocsData;
|
readonly data: DocsData;
|
||||||
}
|
}
|
||||||
@ -137,3 +143,10 @@ export interface MenuLinkGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type MenuItem = MenuLinkGroup | MenuLink;
|
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 yaml from 'js-yaml';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
import { getAnchor } from '../components/docs/components/headers/hooks/useAnchor';
|
||||||
import { SUMMARY_MIN_PARAGRAPH_LENGTH } from '../constants';
|
import { SUMMARY_MIN_PARAGRAPH_LENGTH } from '../constants';
|
||||||
import menu from './menu';
|
import menu from './menu';
|
||||||
|
|
||||||
import type { GetStaticProps } from 'next';
|
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[];
|
docsGroups: DocsGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,6 +60,37 @@ export function fetchDocsMatter(): FileMatter[] {
|
|||||||
return docsMatterCache;
|
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[]] {
|
export function fetchDocsContent(): [DocsPage[], DocsGroup[]] {
|
||||||
if (docsCache && process.env.NODE_ENV !== 'development') {
|
if (docsCache && process.env.NODE_ENV !== 'development') {
|
||||||
return docsCache;
|
return docsCache;
|
||||||
@ -76,7 +119,11 @@ export function fetchDocsContent(): [DocsPage[], DocsGroup[]] {
|
|||||||
...data,
|
...data,
|
||||||
slug,
|
slug,
|
||||||
} as DocsData,
|
} as DocsData,
|
||||||
summary: summaryMatch && summaryMatch.length >= 2 ? summaryMatch[1] : content,
|
textContent: getTextContent(content),
|
||||||
|
headings: getHeadings(content).map(heading => ({
|
||||||
|
title: heading,
|
||||||
|
anchor: getAnchor(heading),
|
||||||
|
})),
|
||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -103,10 +150,30 @@ export function fetchDocsContent(): [DocsPage[], DocsGroup[]] {
|
|||||||
return docsCache;
|
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 } => {
|
export const getDocsMenuStaticProps: GetStaticProps = (): { props: DocsMenuProps } => {
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
docsGroups: fetchDocsContent()[1],
|
docsGroups: fetchDocsContent()[1],
|
||||||
|
searchablePages: getSearchablePages()
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -78,7 +78,7 @@ const Community = ({ docsGroups }: DocsMenuProps) => {
|
|||||||
<StyledCommunityContent>
|
<StyledCommunityContent>
|
||||||
<Container>
|
<Container>
|
||||||
<StyledTitle>
|
<StyledTitle>
|
||||||
<Typography variant="h1" color="secondary">
|
<Typography variant="h1" color="primary">
|
||||||
{communityData.title}
|
{communityData.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h2" color="text.primary">
|
<Typography variant="h2" color="text.primary">
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
|
import Alert from '@mui/material/Alert';
|
||||||
import { styled, useTheme } from '@mui/material/styles';
|
import { styled, useTheme } from '@mui/material/styles';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Alert from '@mui/material/Alert';
|
|
||||||
import { MDXRemote } from 'next-mdx-remote';
|
import { MDXRemote } from 'next-mdx-remote';
|
||||||
import { serialize } from 'next-mdx-remote/serialize';
|
import { serialize } from 'next-mdx-remote/serialize';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
@ -22,11 +22,11 @@ import DocsContent from '../../components/docs/DocsContent';
|
|||||||
import DocsLeftNav from '../../components/docs/DocsLeftNav';
|
import DocsLeftNav from '../../components/docs/DocsLeftNav';
|
||||||
import DocsRightNav from '../../components/docs/DocsRightNav';
|
import DocsRightNav from '../../components/docs/DocsRightNav';
|
||||||
import Page from '../../components/layout/Page';
|
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 { MDXRemoteSerializeResult } from 'next-mdx-remote';
|
||||||
import type { GetStaticPaths, GetStaticProps } from 'next/types';
|
import type { GetStaticPaths, GetStaticProps } from 'next/types';
|
||||||
import type { DocsGroup, DocsPage } from '../../interface';
|
import type { DocsGroup, DocsPage, SearchablePage } from '../../interface';
|
||||||
|
|
||||||
const StyledDocsView = styled('div')(
|
const StyledDocsView = styled('div')(
|
||||||
({ theme }) => `
|
({ theme }) => `
|
||||||
@ -74,13 +74,21 @@ const StyledDocsContentWrapper = styled('main')(
|
|||||||
|
|
||||||
interface DocsProps {
|
interface DocsProps {
|
||||||
docsGroups: DocsGroup[];
|
docsGroups: DocsGroup[];
|
||||||
|
searchablePages: SearchablePage[];
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
source: MDXRemoteSerializeResult;
|
source: MDXRemoteSerializeResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Docs = ({ docsGroups, title, slug, description = '', source }: DocsProps) => {
|
const Docs = ({
|
||||||
|
docsGroups,
|
||||||
|
searchablePages,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
description = '',
|
||||||
|
source,
|
||||||
|
}: DocsProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -89,6 +97,7 @@ const Docs = ({ docsGroups, title, slug, description = '', source }: DocsProps)
|
|||||||
url={`/docs/${slug}`}
|
url={`/docs/${slug}`}
|
||||||
description={description}
|
description={description}
|
||||||
docsGroups={docsGroups}
|
docsGroups={docsGroups}
|
||||||
|
searchablePages={searchablePages}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
<DocsLeftNav docsGroups={docsGroups} />
|
<DocsLeftNav docsGroups={docsGroups} />
|
||||||
@ -160,6 +169,7 @@ export const getStaticProps: GetStaticProps = async ({ params }): Promise<{ prop
|
|||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
docsGroups,
|
docsGroups,
|
||||||
|
searchablePages: getSearchablePages(),
|
||||||
title: data.title,
|
title: data.title,
|
||||||
slug: data.slug,
|
slug: data.slug,
|
||||||
description: '',
|
description: '',
|
||||||
|
@ -256,7 +256,7 @@ const Home = ({ docsGroups }: DocsMenuProps) => {
|
|||||||
<StyledIntroSection>
|
<StyledIntroSection>
|
||||||
<Container>
|
<Container>
|
||||||
<StyledIntroSectionContent>
|
<StyledIntroSectionContent>
|
||||||
<Typography variant="h1" color="secondary">
|
<Typography variant="h1" color="primary">
|
||||||
{homepageData.title}
|
{homepageData.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h2" color="text.primary">
|
<Typography variant="h2" color="text.primary">
|
||||||
@ -340,7 +340,7 @@ const Home = ({ docsGroups }: DocsMenuProps) => {
|
|||||||
sx={{ display: 'flex', alignItems: 'center', gap: '8px' }}
|
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" />
|
<DateDisplay date={release.date} format="MMMM dd, yyyy" />
|
||||||
</>
|
</>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
@ -26,7 +26,7 @@ const useCreateTheme = (mode: PaletteMode) => {
|
|||||||
: {
|
: {
|
||||||
mode,
|
mode,
|
||||||
primary: {
|
primary: {
|
||||||
main: '#3A69C7',
|
main: '#5ecffb',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
main: '#5ecffb',
|
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