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