refactor: monorepo setup with lerna (#243)

This commit is contained in:
Daniel Lautzenheiser
2022-12-15 13:44:49 -05:00
committed by GitHub
parent dac29fbf3c
commit 504d95c34f
706 changed files with 16571 additions and 142 deletions

View File

@ -0,0 +1,25 @@
import addMinutes from 'date-fns/addMinutes';
import format from 'date-fns/format';
import parseISO from 'date-fns/parseISO';
import { useEffect, useState } from 'react';
function formatDate(date: Date, dateFormat: string) {
return format(addMinutes(date, date.getTimezoneOffset()), dateFormat);
}
interface DateDisplayProps {
date: string;
format: string;
}
const DateDisplay = ({ date: dateString, format: dateFormat }: DateDisplayProps) => {
const [date, setDate] = useState(`${formatDate(parseISO(dateString), dateFormat)} UTC`);
useEffect(() => {
setDate(format(parseISO(dateString), dateFormat));
}, [dateFormat, dateString]);
return <>{date}</>;
};
export default DateDisplay;

View File

@ -0,0 +1,38 @@
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import { styled } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import type { CommunityLinksSection } from '../../interface';
const StyledCommunitySection = styled('div')(
({ theme }) => `
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
color: ${theme.palette.primary.main}
`,
);
interface CommunitySectionProps {
section: CommunityLinksSection;
}
const CommunitySection = ({ section }: CommunitySectionProps) => {
return (
<StyledCommunitySection>
<Typography variant="h3">{section.title}</Typography>
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
{section.links.map(link => (
<ListItemButton key={link.url} href={link.url} target="_blank">
<ListItemText primary={link.title} secondary={link.description} />
</ListItemButton>
))}
</List>
</StyledCommunitySection>
);
};
export default CommunitySection;

View File

@ -0,0 +1,6 @@
import { createContext } from 'react';
// eslint-disable-next-line @typescript-eslint/no-empty-function
const ColorModeContext = createContext({ toggleColorMode: () => {} });
export default ColorModeContext;

View File

@ -0,0 +1,293 @@
import { styled } from '@mui/material/styles';
const DocsContent = styled('div')(
({ theme }) => `
color: ${theme.palette.text.primary};
font-weight: 200;
width: 100%;
max-width: 1100px;
padding: 0 40px 0 56px;
display: flex;
flex-direction: column;
${theme.breakpoints.between('sm', 'lg')} {
padding: 0 40px;
}
${theme.breakpoints.down('sm')} {
padding: 0 32px;
}
& time {
color: #9b9b9b;
}
& p {
line-height: 1.5rem;
margin: 0 0 16px;
font-size: 16px;
word-break: break-word;
}
& p:not(:first-of-type) {
margin-top: 8px;
}
& pre + p:not(:first-of-type) {
margin-top: 20px;
}
& :not(h1,h2,h3,h4,h5,h6) a {
color: ${theme.palette.primary.main};
text-decoration: none;
font-weight: 500;
}
& a:hover {
text-decoration: underline;
}
& h2,
& h3,
& h4,
& h5,
& h6 {
font-weight: 500;
margin-top: 28px;
margin-bottom: 12px;
position: relative;
}
& h3 {
color: ${theme.palette.text.primary};
}
& h4,
& h5,
& h6 {
color: ${theme.palette.text.secondary};
}
${theme.breakpoints.up('lg')} {
& h1 {
margin-top: 16px;
margin-bottom: 16px;
}
}
& h1 {
font-size: 32px;
line-height: 36px;
}
${theme.breakpoints.between('sm', 'lg')} {
& h1 {
font-size: 26px;
}
}
${theme.breakpoints.down('sm')} {
& h1 {
font-size: 24px;
}
}
& h2 {
color: ${theme.palette.primary.main};
font-size: 26px;
line-height: 26px;
}
${theme.breakpoints.between('sm', 'lg')} {
& h2 {
font-size: 20px;
}
}
${theme.breakpoints.down('sm')} {
& h2 {
font-size: 18px;
}
}
& h3 {
line-height: 22px;
font-size: 24px;
line-height: 24px;
}
${theme.breakpoints.between('sm', 'lg')} {
& h3 {
font-size: 18px;
}
}
${theme.breakpoints.down('sm')} {
& h3 {
font-size: 17px;
}
}
& h4 {
font-size: 20px;
line-height: 20px;
}
${theme.breakpoints.down('lg')} {
& h4 {
font-size: 16px;
}
}
& h5 {
font-size: 18px;
line-height: 19px;
}
${theme.breakpoints.down('lg')} {
& h5 {
font-size: 15px;
}
}
& h6 {
font-size: 16px;
line-height: 18px;
}
${theme.breakpoints.down('lg')} {
& h6 {
font-size: 14px;
}
}
& h1 + h2 {
margin-top: 0;
}
.dark & pre,
.dark & pre[class*='language-'],
.light & pre,
.light & pre[class*='language-'] {
display: block;
line-height: 1.25rem;
padding: 1rem;
overflow: auto;
margin: 0;
}
& pre code {
background-color: transparent;
font-size: 100%;
padding: 0;
}
& code[class*=language-],
& pre[class*=language-] {
text-shadow: none;
border: none;
box-shadow: none;
}
.light & code[class*=language-],
.light & pre[class*=language-] {
background-color: #EDEEEE;
text-shadow: none;
border: none;
box-shadow: none;
}
.dark & code[class*=language-],
.dark & pre[class*=language-] {
background-color: #1E1E1E;
text-shadow: none;
border: none;
box-shadow: none;
}
& code {
font-size: 85%;
padding: 0.2em 0.4em;
margin: 0;
border-radius: 3px;
color: ${theme.palette.text.primary};
background-color: ${
theme.palette.mode === 'light' ? 'rgba(175,184,193,0.2)' : 'rgba(110,118,129,0.75)'
};
}
& blockquote {
margin: 8px 1rem;
}
& blockquote > p {
margin: 0;
}
& blockquote::before {
border-left: 4px solid ${theme.palette.text.secondary};
position: absolute;
content: '';
font-size: 6em;
font-family: roboto, serif;
line-height: 1.5rem;
margin-left: -0.2em;
height: 1.5rem;
z-index: -1;
}
& ol,
& ul {
padding: 0 0 0 1.5rem;
margin: 8px 0;
}
& ol li,
& ul li {
line-height: 1.5rem;
}
& li ol,
& li ul {
margin: 0;
}
& ul + p {
margin-top: 16px;
}
& abbr[title] {
text-decoration: underline double;
}
& kbd {
font-family: 'Oswald', 'Ubuntu Mono', monospace;
}
& img {
max-width: 100%;
}
${theme.breakpoints.down('md')} {
& h2::before {
display: block;
}
}
& b,
& strong {
font-weight: 700;
}
& hr {
display: flex;
width: 100%;
}
.MuiAlert-root {
margin-bottom: 16px;
}
`,
);
export default DocsContent;

View File

@ -0,0 +1,42 @@
import List from '@mui/material/List';
import { useTheme } from '@mui/material/styles';
import DocsLeftNavGroup from './DocsLeftNavGroup';
import type { DocsGroup } from '../../interface';
export interface DocsLeftNavProps {
docsGroups: DocsGroup[];
}
const DocsLeftNav = ({ docsGroups }: DocsLeftNavProps) => {
const theme = useTheme();
return (
<List
component="nav"
aria-labelledby="docs-left-nav"
sx={{
width: '100%',
maxWidth: 280,
bgcolor: 'background.paper',
position: 'fixed',
left: 0,
top: '72px',
bottom: 0,
overflowY: 'auto',
paddingBottom: '24px',
[theme.breakpoints.down('lg')]: {
display: 'none',
},
}}
dense
>
{docsGroups.map(group => (
<DocsLeftNavGroup key={group.name} name={group.title} links={group.links} />
))}
</List>
);
};
export default DocsLeftNav;

View File

@ -0,0 +1,68 @@
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import Collapse from '@mui/material/Collapse';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import { useTheme } from '@mui/material/styles';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
import type { DocsGroupLink } from '../../interface';
export interface DocsLeftNavGroupProps {
name: string;
links: DocsGroupLink[];
}
const DocsLeftNavGroup = ({ name, links }: DocsLeftNavGroupProps) => {
const theme = useTheme();
const [open, setOpen] = useState(true);
const { asPath } = useRouter();
const handleClick = () => {
setOpen(!open);
};
return (
<>
<ListItemButton onClick={handleClick}>
<ListItemText primary={name} />
<ExpandLessIcon
sx={{
transform: `rotateZ(${open ? 0 : 90}deg)`,
transition: theme.transitions.create(['transform']),
}}
/>
</ListItemButton>
<Collapse in={open} timeout="auto" unmountOnExit>
<List component="div" disablePadding dense>
{links.map(link => {
const url = `/docs/${link.slug}`;
const selected = asPath === url;
return (
<ListItemButton
key={link.slug}
component={Link}
href={url}
sx={{
pl: 4,
}}
selected={selected}
>
<ListItemText
primaryTypographyProps={{
color: selected ? theme.palette.primary.main : theme.palette.text.secondary,
fontWeight: selected ? 600 : 400,
}}
primary={link.title}
/>
</ListItemButton>
);
})}
</List>
</Collapse>
</>
);
};
export default DocsLeftNavGroup;

View File

@ -0,0 +1,7 @@
import DocsTableOfContents from './table_of_contents/DocsTableOfContents';
const DocsRightNav = () => {
return <DocsTableOfContents />;
};
export default DocsRightNav;

View File

@ -0,0 +1,57 @@
import Link from 'next/link';
import { useMemo } from 'react';
import type { DetailedHTMLProps, AnchorHTMLAttributes, ReactNode } from 'react';
enum LinkType {
SAME_SITE,
SAME_PAGE,
EXTERNAL,
}
interface AnchorProps
extends DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement> {
children?: ReactNode;
}
const Anchor = ({ href = '', children = '' }: AnchorProps) => {
const type: LinkType = useMemo(() => {
if (href.startsWith('#')) {
return LinkType.SAME_PAGE;
}
if (href.startsWith('/') || href.startsWith('.')) {
return LinkType.SAME_SITE;
}
return LinkType.EXTERNAL;
}, [href]);
if (type === LinkType.SAME_PAGE) {
return (
<a
href={href}
onClick={e => {
e.preventDefault();
document.querySelector(href)?.scrollIntoView({
behavior: 'smooth',
});
}}
>
{children}
</a>
);
}
if (type === LinkType.SAME_SITE) {
return <Link href={href}>{children}</Link>;
}
return (
<a href={href} target="_blank" rel="noreferrer">
{children}
</a>
);
};
export default Anchor;

View File

@ -0,0 +1,71 @@
import { styled } from '@mui/material/styles';
import { useMemo } from 'react';
import type { ReactNode } from 'react';
interface StyledBlockquoteProps {
$color: 'success' | 'error' | 'default';
}
const StyledBlockquote = styled('blockquote')<StyledBlockquoteProps>(
({ theme, $color }) => `
${
$color !== 'default'
? `
blockquote&::before {
border-left: 4px solid ${
$color === 'success' ? theme.palette.success.main : theme.palette.error.main
};
}
`
: ''
}
`,
);
const getNodeText = (node: ReactNode): string => {
if (!node) {
return '';
}
if (typeof node === 'string' || typeof node === 'boolean' || typeof node === 'number') {
return `${node}`;
}
if (node instanceof Array) {
return node.map(getNodeText).join('');
}
if ('props' in node && 'children' in node.props) {
return getNodeText(node.props.children);
}
return '';
};
interface BlockquoteProps {
children?: ReactNode;
}
const Blockquote = ({ children = '' }: BlockquoteProps) => {
const color = useMemo(() => {
const text = getNodeText(children).trim();
if (text === '') {
return 'default';
}
if (text.startsWith('Do: ')) {
return 'success';
}
if (text.startsWith("Don't: ")) {
return 'error';
}
return 'default';
}, [children]);
return <StyledBlockquote $color={color}>{children}</StyledBlockquote>;
};
export default Blockquote;

View File

@ -0,0 +1,142 @@
import Box from '@mui/material/Box';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import Prism from 'prismjs';
import { isValidElement, useMemo, useState } from 'react';
import { isNotEmpty } from '../../../util/string.util';
import type { Grammar } from 'prismjs';
import type { ReactElement, ReactNode, SyntheticEvent } from 'react';
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
>
{value === index && <Box>{children}</Box>}
</div>
);
}
function a11yProps(index: number) {
return {
id: `simple-tab-${index}`,
'aria-controls': `simple-tabpanel-${index}`,
};
}
interface CodeLanguage {
title: string;
grammar: Grammar;
language: string;
}
const supportedLanguages: Record<string, CodeLanguage> = {
'language-yaml': {
title: 'Yaml',
grammar: Prism.languages.yaml,
language: 'yaml',
},
'language-js': {
title: 'JavaScript',
grammar: Prism.languages.javascript,
language: 'javascript',
},
'language-markdown': {
title: 'Markdown',
grammar: Prism.languages.markdown,
language: 'markdown',
},
};
interface TabData {
title: string;
className: string;
content: string;
}
interface CodeTabsProps {
children?: ReactNode;
}
const CodeTabs = ({ children }: CodeTabsProps) => {
const [value, setValue] = useState(0);
const handleChange = (_event: SyntheticEvent, newValue: number) => {
setValue(newValue);
};
const tabs = useMemo(() => {
if (!children || !Array.isArray(children)) {
return [];
}
return children
.filter((child: ReactNode) => isValidElement(child) && child.type === 'pre')
.map((child: ReactElement) => child.props.children)
.filter((subChild: ReactNode) => isValidElement(subChild) && subChild.type === 'code')
.map((code: ReactElement) => {
if (!(code.props.className in supportedLanguages)) {
return false;
}
const language = supportedLanguages[code.props.className];
return {
title: language.title,
className: code.props.className,
content:
typeof code.props.children === 'string' && isNotEmpty(code.props.children)
? Prism.highlight(code.props.children, language.grammar, language.language)
: '',
};
})
.filter(Boolean) as TabData[];
}, [children]);
if (tabs.length === 0) {
return null;
}
return (
<Box sx={{ width: '100%', margin: '8px 0 16px' }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={value}
onChange={handleChange}
aria-label="basic tabs example"
sx={{ '.MuiTabs-root': { margin: 0 }, '.MuiTabs-flexContainer': { margin: 0 } }}
>
{tabs.map((tabData, index) => (
<Tab key={tabData.className} label={tabData.title} {...a11yProps(index)} />
))}
</Tabs>
</Box>
{tabs.map((tabData, index) => (
<TabPanel key={tabData.className} value={value} index={index}>
<pre className={tabData.className}>
<code
className={tabData.className}
dangerouslySetInnerHTML={{ __html: tabData.content }}
/>
</pre>
</TabPanel>
))}
</Box>
);
};
export default CodeTabs;

View File

@ -0,0 +1,23 @@
import Typography from '@mui/material/Typography';
import { useNodeText } from '../../../../util/node.util';
import useAnchor from './hooks/useAnchor';
import type { ReactNode } from 'react';
interface Header1Props {
children?: ReactNode;
}
const Header1 = ({ children }: Header1Props) => {
const textContent = useNodeText(children);
const anchor = useAnchor(textContent);
return (
<Typography variant="h1" id={anchor}>
{children}
</Typography>
);
};
export default Header1;

View File

@ -0,0 +1,13 @@
import LinkedHeader from './components/LinkedHeader';
import type { ReactNode } from 'react';
interface Header2Props {
children?: ReactNode;
}
const Header2 = ({ children }: Header2Props) => {
return <LinkedHeader variant="h2">{children}</LinkedHeader>;
};
export default Header2;

View File

@ -0,0 +1,13 @@
import LinkedHeader from './components/LinkedHeader';
import type { ReactNode } from 'react';
interface Header3Props {
children?: ReactNode;
}
const Header3 = ({ children }: Header3Props) => {
return <LinkedHeader variant="h3">{children}</LinkedHeader>;
};
export default Header3;

View File

@ -0,0 +1,13 @@
import LinkedHeader from './components/LinkedHeader';
import type { ReactNode } from 'react';
interface Header4Props {
children?: ReactNode;
}
const Header4 = ({ children }: Header4Props) => {
return <LinkedHeader variant="h4">{children}</LinkedHeader>;
};
export default Header4;

View File

@ -0,0 +1,13 @@
import LinkedHeader from './components/LinkedHeader';
import type { ReactNode } from 'react';
interface Header5Props {
children?: ReactNode;
}
const Header5 = ({ children }: Header5Props) => {
return <LinkedHeader variant="h5">{children}</LinkedHeader>;
};
export default Header5;

View File

@ -0,0 +1,13 @@
import LinkedHeader from './components/LinkedHeader';
import type { ReactNode } from 'react';
interface Header6Props {
children?: ReactNode;
}
const Header6 = ({ children }: Header6Props) => {
return <LinkedHeader variant="h6">{children}</LinkedHeader>;
};
export default Header6;

View File

@ -0,0 +1,45 @@
import LinkIcon from '@mui/icons-material/Link';
import { useTheme } from '@mui/material/styles';
import { useMemo } from 'react';
interface AnchorLinkIconProps {
variant: 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
}
const AnchorLinkIcon = ({ variant }: AnchorLinkIconProps) => {
const theme = useTheme();
const color = useMemo(() => {
if (variant === 'h2') {
return theme.palette.primary.main;
}
if (variant === 'h3') {
return theme.palette.text.primary;
}
return theme.palette.text.secondary;
}, [
theme.palette.primary.main,
theme.palette.text.primary,
theme.palette.text.secondary,
variant,
]);
return (
<LinkIcon
fontSize={variant === 'h2' ? 'medium' : 'small'}
sx={{
color,
[theme.breakpoints.down('sm')]: {
fontSize: '20px',
height: '20px',
width: '20px',
marginTop: '2px',
},
}}
/>
);
};
export default AnchorLinkIcon;

View File

@ -0,0 +1,61 @@
import { styled, useTheme } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import Link from 'next/link';
import { useMemo } from 'react';
import { useNodeText } from '../../../../../util/node.util';
import { isNotEmpty } from '../../../../../util/string.util';
import useAnchor from '../hooks/useAnchor';
import AnchorLinkIcon from './AnchorLinkIcon';
import type { ReactNode } from 'react';
const StyledLink = styled(Link)(
({ theme }) => `
position: absolute;
margin-left: -28px;
top: 0;
font-weight: 300;
color: ${theme.palette.text.primary};
transform: rotateZ(-45deg);
${theme.breakpoints.down('sm')} {
margin-left: -22px;
top: -1px;
}
`,
);
interface Header3Props {
variant: 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
children?: ReactNode;
}
const Header3 = ({ variant, children = '' }: Header3Props) => {
const textContent = useNodeText(children);
const anchor = useAnchor(textContent);
const link = useMemo(() => `#${anchor}`, [anchor]);
const theme = useTheme();
const hasText = useMemo(() => isNotEmpty(textContent), [textContent]);
return (
<Typography
variant={variant}
component={hasText ? variant : 'div'}
id={anchor}
sx={{
[theme.breakpoints.down('sm')]: {
marginLeft: '8px',
},
}}
>
{hasText ? (
<StyledLink href={link}>
<AnchorLinkIcon variant={variant} />
</StyledLink>
) : null}
{children}
</Typography>
);
};
export default Header3;

View File

@ -0,0 +1,15 @@
import { useMemo } from 'react';
export const getAnchor = (text: string) => {
return text
.trim()
.toLowerCase()
.replace(/[^a-z0-9 \-_]/g, '')
.replace(/[ ]/g, '-');
};
const useAnchor = (text: string) => {
return useMemo(() => getAnchor(text), [text]);
};
export default useAnchor;

View File

@ -0,0 +1,61 @@
import { styled } from '@mui/material/styles';
import Table from '@mui/material/Table';
import TableContainer from '@mui/material/TableContainer';
import type { ReactNode } from 'react';
const StyledTableContainer = styled(TableContainer)(
({ theme }) => `
margin-bottom: 16px;
& td {
color: ${theme.palette.text.secondary};
}
& td:nth-of-type(2):not(:last-of-type) {
color:
${theme.palette.mode === 'light' ? '#751365' : '#ffb6ec'};
}
& thead tr th,
& thead tr td {
white-space: nowrap;
}
& tbody tr td {
white-space: nowrap;
}
& tbody tr td:last-of-type {
white-space: normal;
}
.non-props-table + & tbody tr td {
white-space: normal;
}
& tbody tr td:last-of-type {
min-width: 200px;
}
.non-props-table + & tbody tr td:last-of-type {
min-width: unset;
}
`,
);
interface DocsTableProps {
children?: ReactNode | ReactNode[];
}
const DocsTable = ({ children = [] }: DocsTableProps) => {
return (
<StyledTableContainer>
<Table sx={{ width: '100%' }} aria-label="doc table">
{children}
</Table>
</StyledTableContainer>
);
};
export default DocsTable;

View File

@ -0,0 +1,13 @@
import MuiTableBody from '@mui/material/TableBody';
import type { ReactNode } from 'react';
interface TableBodyProps {
children?: ReactNode;
}
const TableBody = ({ children }: TableBodyProps) => {
return <MuiTableBody>{children}</MuiTableBody>;
};
export default TableBody;

View File

@ -0,0 +1,37 @@
import TableCell from '@mui/material/TableCell';
import Typography from '@mui/material/Typography';
import type { ReactNode } from 'react';
interface TableBodyCellProps {
children?: ReactNode;
}
const TableBodyCell = ({ children }: TableBodyCellProps) => {
return (
<TableCell
scope="row"
sx={{
padding: '16px 12px',
'&:first-of-type, &:first-of-type': {
paddingLeft: 0,
},
'&:last-of-type, &:last-of-type': {
paddingRight: 0,
},
}}
>
<Typography
component="span"
sx={{
fontSize: '13px',
fontFamily: 'Consolas, Menlo, Monaco, Andale Mono, Ubuntu Mono, monospace',
}}
>
{children}
</Typography>
</TableCell>
);
};
export default TableBodyCell;

View File

@ -0,0 +1,13 @@
import MuiTableHead from '@mui/material/TableHead';
import type { ReactNode } from 'react';
interface TableHeadProps {
children?: ReactNode;
}
const TableHead = ({ children }: TableHeadProps) => {
return <MuiTableHead>{children}</MuiTableHead>;
};
export default TableHead;

View File

@ -0,0 +1,28 @@
import TableCell from '@mui/material/TableCell';
import type { ReactNode } from 'react';
interface TableHeaderCellProps {
children?: ReactNode;
}
const TableHeaderCell = ({ children }: TableHeaderCellProps) => {
return (
<TableCell
sx={{
fontWeight: 600,
padding: '16px 12px',
'&:first-of-type, &:first-of-type': {
paddingLeft: 0,
},
'&:last-of-type, &:last-of-type': {
paddingRight: 0,
},
}}
>
{children}
</TableCell>
);
};
export default TableHeaderCell;

View File

@ -0,0 +1,15 @@
import MuiTableRow from '@mui/material/TableRow';
import type { ReactNode } from 'react';
interface TableRowProps {
children?: ReactNode;
}
const TableRow = ({ children }: TableRowProps) => {
return (
<MuiTableRow sx={{ '&:last-child td, &:last-child th': { border: 0 } }}>{children}</MuiTableRow>
);
};
export default TableRow;

View File

@ -0,0 +1,95 @@
import { styled } from '@mui/material/styles';
import type { NestedHeading } from './DocsTableOfContents';
const StyledList = styled('ul')(
({ theme }) => `
display: flex;
flex-direction: column;
list-style-type: none;
padding: 0;
${theme.breakpoints.down('lg')} {
margin-top: 0;
}
`,
);
const StyledListItem = styled('li')(
({ theme }) => `
& > a {
display: flex;
padding-left: 32px;
text-indent: -16px;
color: ${theme.palette.text.secondary};
text-decoration: none;
font-weight: 500;
font-size: 14px;
line-height: 28px;
border-left: 2px solid transparent;
}
&.active > a {
color: ${theme.palette.primary.main};
border-left: 2px solid ${theme.palette.primary.main};
}
& > a:hover {
color: ${theme.palette.primary.main};
text-decoration: underline;
}
`,
);
const StyledChildListItem = styled(StyledListItem)`
& > a {
padding-left: 48px;
text-indent: -16px;
}
`;
interface DocsHeadingsProps {
headings: NestedHeading[];
activeId: string | undefined;
}
const DocsHeadings = ({ headings, activeId }: DocsHeadingsProps) => (
<StyledList>
{headings.map(heading => (
<StyledListItem key={heading.id} className={heading.id === activeId ? 'active' : ''}>
<a
href={`#${heading.id}`}
onClick={e => {
e.preventDefault();
document.querySelector(`#${heading.id}`)?.scrollIntoView({
behavior: 'smooth',
});
}}
>
{heading.title}
</a>
{heading.items.length > 0 && (
<StyledList>
{heading.items.map(child => (
<StyledChildListItem key={child.id} className={child.id === activeId ? 'active' : ''}>
<a
href={`#${child.id}`}
onClick={e => {
e.preventDefault();
document.querySelector(`#${child.id}`)?.scrollIntoView({
behavior: 'smooth',
});
}}
>
{child.title}
</a>
</StyledChildListItem>
))}
</StyledList>
)}
</StyledListItem>
))}
</StyledList>
);
export default DocsHeadings;

View File

@ -0,0 +1,144 @@
import { styled } from '@mui/material/styles';
import { useRouter } from 'next/router';
import { useEffect, useRef, useState } from 'react';
import { isNotEmpty } from '../../../util/string.util';
import DocsHeadings from './DocsHeadings';
export interface Heading {
id: string;
title: string;
}
export interface NestedHeading extends Heading {
items: Heading[];
}
const getNestedHeadings = (headingElements: HTMLHeadingElement[]) => {
const nestedHeadings: NestedHeading[] = [];
headingElements.forEach(heading => {
const { innerText: title, id } = heading;
if (heading.nodeName === 'H1' || heading.nodeName === 'H2') {
nestedHeadings.push({ id, title, items: [] });
} else if (heading.nodeName === 'H3' && nestedHeadings.length > 0) {
nestedHeadings[nestedHeadings.length - 1].items.push({
id,
title,
});
}
});
return nestedHeadings;
};
const useHeadingsData = () => {
const [nestedHeadings, setNestedHeadings] = useState<NestedHeading[]>([]);
const { asPath } = useRouter();
useEffect(() => {
const headingElements = Array.from(
document.querySelectorAll<HTMLHeadingElement>('main h1, main h2, main h3'),
);
// Created a list of headings, with H3s nested
const newNestedHeadings = getNestedHeadings(headingElements);
setNestedHeadings(newNestedHeadings);
}, [asPath]);
return { nestedHeadings };
};
const useIntersectionObserver = (setActiveId: (activeId: string) => void) => {
const headingElementsRef = useRef<Record<string, IntersectionObserverEntry>>({});
const { asPath } = useRouter();
useEffect(() => {
const headingElements = Array.from(
document.querySelectorAll<HTMLHeadingElement>('main h1, main h2, main h3'),
);
if (headingElementsRef.current) {
headingElementsRef.current = {};
}
const callback: IntersectionObserverCallback = headings => {
headingElementsRef.current = headings.reduce((map, headingElement) => {
map[headingElement.target.id] = headingElement;
return map;
}, headingElementsRef.current as Record<string, IntersectionObserverEntry>);
// Get all headings that are currently visible on the page
const visibleHeadings: IntersectionObserverEntry[] = [];
Object.keys(headingElementsRef.current).forEach(key => {
const headingElement = (
headingElementsRef.current as Record<string, IntersectionObserverEntry>
)[key];
if (headingElement.isIntersecting && isNotEmpty(headingElement.target.textContent)) {
visibleHeadings.push(headingElement);
}
});
const getIndexFromId = (id: string) =>
headingElements.findIndex(heading => heading.id === id);
// If there is only one visible heading, this is our "active" heading
if (visibleHeadings.length === 1) {
setActiveId(visibleHeadings[0].target.id);
// If there is more than one visible heading,
// choose the one that is closest to the top of the page
} else if (visibleHeadings.length > 1) {
const sortedVisibleHeadings = visibleHeadings.sort((a, b) =>
getIndexFromId(a.target.id) > getIndexFromId(b.target.id) ? 1 : -1,
);
setActiveId(sortedVisibleHeadings[0].target.id);
}
};
const observer = new IntersectionObserver(callback, {
rootMargin: '0px 0px -36px 0px',
});
headingElements.forEach(element => observer.observe(element));
return () => {
observer.disconnect();
};
}, [setActiveId, asPath]);
};
const StyledNav = styled('nav')(
({ theme }) => `
width: 100%;
padding: 0 20px 16px 0;
align-self: flex-start;
position: sticky;
top: 0;
max-height: calc(100vh - 72px);
overflow-y: auto;
top: 16px;
${theme.breakpoints.between('md', 'lg')} {
top: 24px;
}
${theme.breakpoints.down('md')} {
display: none;
}
`,
);
const DocsTableOfContents = () => {
const [activeId, setActiveId] = useState<string>();
const { nestedHeadings } = useHeadingsData();
useIntersectionObserver(setActiveId);
return (
<StyledNav aria-label="Table of contents">
<DocsHeadings headings={nestedHeadings} activeId={activeId} />
</StyledNav>
);
};
export default DocsTableOfContents;

View File

@ -0,0 +1,28 @@
import { styled } from '@mui/material/styles';
import type { ReactNode } from 'react';
const StyledContainer = styled('div')(
({ theme }) => `
max-width: 1280px;
width: 100%;
padding: 0 40px;
display: flex;
flex-direction: column;
align-items: center;
${theme.breakpoints.down('md')} {
padding: 0 32px;
}
`,
);
export interface PageProps {
children: ReactNode;
}
const Container = ({ children }: PageProps) => {
return <StyledContainer>{children}</StyledContainer>;
};
export default Container;

View File

@ -0,0 +1,69 @@
import Button from '@mui/material/Button';
import Link from '@mui/material/Link';
import { styled } from '@mui/material/styles';
import config from '../../lib/config';
import Container from './Container';
const StyledFooter = styled('footer')(
({ theme }) => `
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
background: ${theme.palette.mode === 'light' ? '#dddee2' : '#242424'};
padding: 24px 0 40px;
`,
);
const StyledFooterContent = styled('footer')`
width: 100%;
display: flex;
gap: 40px;
align-items: center;
`;
const StyledButtons = styled('div')`
display: flex;
gap: 8px;
`;
const StyledLinks = styled('div')`
display: flex;
gap: 8px;
`;
const StyledLink = styled(Link)`
text-decoration: none;
&:hover {
text-decoration: underline;
}
`;
const Footer = () => {
return (
<StyledFooter>
<Container>
<StyledFooterContent>
<StyledButtons>
{config.footer.buttons.map(button => (
<Button key={button.url} href={button.url} target="_blank" variant="contained">
{button.text}
</Button>
))}
</StyledButtons>
<StyledLinks>
{config.footer.links.map(link => (
<StyledLink key={link.url} href={link.url} target="_blank" color="text.primary">
{link.text}
</StyledLink>
))}
</StyledLinks>
</StyledFooterContent>
</Container>
</StyledFooter>
);
};
export default Footer;

View File

@ -0,0 +1,209 @@
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';
import IconButton from '@mui/material/IconButton';
import { styled, useTheme } from '@mui/material/styles';
import Toolbar from '@mui/material/Toolbar';
import Link from 'next/link';
import { useCallback, useMemo, useState } from 'react';
import Logo from './Logo';
import NavigationDrawer from './mobile-drawer/NavigationDrawer';
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, SearchablePage } from '../../interface';
const StyledAppBar = styled(AppBar)(
({ theme }) => `
background: ${theme.palette.mode === 'light' ? theme.palette.primary.main : '#121212'};
`,
);
const StyledToolbar = styled(Toolbar)(
({ theme }) => `
gap: 16px;
height: 72px;
${theme.breakpoints.down('lg')} {
justify-content: space-between;
}
`,
);
const StyledIconsWrapper = styled('div')(
({ theme }) => `
display: flex;
align-items: center;
justify-content: center;
${theme.breakpoints.up('lg')} {
flex-grow: 1;
}
`,
);
const StyledGithubLink = styled('a')(
({ theme }) => `
display: flex;
align-items: center;
${theme.breakpoints.down('lg')} {
display: none;
}
`,
);
const StyledGithubImage = styled('img')`
display: flex;
`;
const StyledMenuButton = styled(IconButton)(
({ theme }) => `
${theme.breakpoints.up('lg')} {
visibility: hidden;
height: 0;
width: 0;
padding: 0;
}
`,
);
const StyledDesktopGap = styled('div')(
({ theme }) => `
flex-grow: 1;
${theme.breakpoints.down('lg')} {
display: none;
}
`,
);
const StyledDesktopLink = styled(Button)(
({ theme }) => `
color: white;
&:hover {
color: rgba(255, 255, 255, 0.6);
}
${theme.breakpoints.down('lg')} {
display: none;
}
`,
) as ExtendButtonBase<ButtonTypeMap<{}, 'a'>>;
interface HeaderProps {
mode: PaletteMode;
docsGroups: DocsGroup[];
searchablePages: SearchablePage[];
toggleColorMode: () => void;
}
const Header = ({ mode, docsGroups, searchablePages, toggleColorMode }: HeaderProps) => {
const theme = useTheme();
const [mobileOpen, setMobileOpen] = useState(false);
const handleDrawerToggle = useCallback(() => {
setMobileOpen(!mobileOpen);
}, [mobileOpen]);
const items: MenuItem[] = useMemo(
() => [
{
title: 'Docs',
path: '/docs',
groups: docsGroups.map(group => ({
title: group.title,
links: group.links.map(link => ({
title: link.title,
url: `/docs/${link.slug}`,
})),
})),
},
{
title: 'Contributing',
url: '/docs/contributor-guide',
},
{
title: 'Community',
url: '/community',
},
],
[docsGroups],
);
return (
<>
<StyledAppBar position="fixed">
<StyledToolbar>
<StyledMenuButton
size="large"
edge="start"
color="inherit"
aria-label="menu"
onClick={handleDrawerToggle}
>
<MenuIcon fontSize="large" />
</StyledMenuButton>
<Logo />
<StyledIconsWrapper>
<Search searchablePages={searchablePages} />
<IconButton
sx={{ [theme.breakpoints.up('lg')]: { ml: 1 } }}
onClick={toggleColorMode}
color="inherit"
title={mode === 'dark' ? 'Turn on the light' : 'Turn off the light'}
>
{mode === 'dark' ? <Brightness7Icon /> : <Brightness4Icon />}
</IconButton>
<StyledDesktopGap />
<StyledGithubLink
href="https://github.com/StaticJsCMS/static-cms"
aria-label="Star StaticJsCMS/static-cms on GitHub"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<StyledGithubImage
alt="Star StaticJsCMS/static-cms on GitHub"
src="https://img.shields.io/github/stars/StaticJsCMS/static-cms?style=social"
/>
</StyledGithubLink>
<IconButton href="https://github.com/StaticJsCMS/static-cms" color="inherit">
<GitHubIcon />
</IconButton>
</StyledIconsWrapper>
{items.map(item => {
let url = '#';
if ('url' in item) {
url = item.url;
} else if (item.groups.length > 0 && item.groups[0].links.length > 0) {
url = item.groups[0].links[0].url;
}
return (
<StyledDesktopLink key={`desktop-${item.title}-${url}`} component={Link} href={url}>
{item.title}
</StyledDesktopLink>
);
})}
{/*
<StyledDesktopLink component={Link} href="/blog">Blog</StyledDesktopLink>
*/}
</StyledToolbar>
</StyledAppBar>
<NavigationDrawer
key="mobile-navigation-drawer"
items={items}
mobileOpen={mobileOpen}
onMobileOpenToggle={handleDrawerToggle}
/>
</>
);
};
export default Header;

View File

@ -0,0 +1,22 @@
import { styled } from '@mui/material/styles';
import Image from 'next/image';
import Link from 'next/link';
const StyledImageLink = styled(Link)`
display: flex;
align-items: center;
`;
const StyledImage = styled(Image)`
cursor: pointer;
`;
const Logo = () => {
return (
<StyledImageLink href="/">
<StyledImage src="/static-cms-logo.svg" alt="Static CMS" width={182} height={72} />
</StyledImageLink>
);
};
export default Logo;

View File

@ -0,0 +1,107 @@
import CssBaseline from '@mui/material/CssBaseline';
import { styled, ThemeProvider, useTheme } from '@mui/material/styles';
import { useRouter } from 'next/router';
import { useContext, useEffect, useMemo, useRef } from 'react';
import ColorModeContext from '../context/ColorModeContext';
import BasicMeta from '../meta/BasicMeta';
import JsonLdMeta from '../meta/JsonLdMeta';
import OpenGraphMeta from '../meta/OpenGraphMeta';
import TwitterCardMeta from '../meta/TwitterCardMeta';
import Container from './Container';
import Header from './Header';
import type { ReactNode } from 'react';
import type { DocsGroup, SearchablePage } from '../../interface';
const StyledPageContentWrapper = styled('div')`
display: block;
height: calc(100vh - 72px);
width: 100%;
position: relative;
top: 72px;
overflow-y: auto;
overflow-x: hidden;
`;
export interface PageProps {
title?: string;
url: string;
keywords?: string[];
description?: string;
children: ReactNode;
pageDetails?: {
date?: Date;
image?: string;
};
fullWidth?: boolean;
docsGroups: DocsGroup[];
searchablePages: SearchablePage[];
}
const Page = ({
children,
title,
url,
keywords,
description,
pageDetails,
fullWidth = false,
docsGroups,
searchablePages,
}: PageProps) => {
const scrollableArea = useRef<HTMLDivElement | null>(null);
const theme = useTheme();
const colorMode = useContext(ColorModeContext);
const { asPath } = useRouter();
const content = useMemo(() => {
if (fullWidth) {
return children;
}
return <Container>{children}</Container>;
}, [children, fullWidth]);
useEffect(() => {
if (!asPath.includes('#')) {
scrollableArea.current?.scrollTo({
top: 0,
behavior: 'auto',
});
}
}, [asPath]);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<BasicMeta url={url} title={title} keywords={keywords} description={description} />
<OpenGraphMeta url={url} title={title} image={pageDetails?.image} description={description} />
<TwitterCardMeta
url={url}
title={title}
image={pageDetails?.image}
description={description}
/>
{pageDetails ? (
<JsonLdMeta
url={url}
title={title}
keywords={keywords}
date={pageDetails.date}
image={pageDetails.image}
description={description}
/>
) : null}
<Header
mode={theme.palette.mode}
docsGroups={docsGroups}
searchablePages={searchablePages}
toggleColorMode={colorMode.toggleColorMode}
/>
<StyledPageContentWrapper ref={scrollableArea}>{content}</StyledPageContentWrapper>
</ThemeProvider>
);
};
export default Page;

View File

@ -0,0 +1,127 @@
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import Collapse from '@mui/material/Collapse';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import { useTheme } from '@mui/material/styles';
import Link from 'next/link';
import { useCallback, useMemo, useState } from 'react';
import ListSubheader from '@mui/material/ListSubheader';
import { useRouter } from 'next/router';
import MobileNavLink from './MobileNavLink';
import type { MouseEvent } from 'react';
import type { MenuItem, MenuLink, MenuLinkGroup } from '../../../interface';
interface MobileNavItemProps {
item: MenuItem;
}
function isMenuLinkGroup(link: MenuItem): link is MenuLinkGroup {
return 'groups' in link;
}
const MobileNavItem = ({ item }: MobileNavItemProps) => {
const theme = useTheme();
const { asPath } = useRouter();
const selected = useMemo(() => {
if ('url' in item) {
return asPath === item.url;
}
return asPath.startsWith(item.path);
}, [asPath, item]);
const [open, setOpen] = useState(selected);
const handleOnClick = useCallback(
(link: MenuItem | MenuLink) => (event: MouseEvent) => {
if (isMenuLinkGroup(link)) {
event.stopPropagation();
setOpen(!open);
return;
}
},
[open],
);
const url = useMemo(() => {
if (isMenuLinkGroup(item)) {
return undefined;
}
return item.url;
}, [item]);
const wrappedLink = useMemo(
() => (
<ListItemButton
component={url ? Link : 'button'}
href={url}
target={url?.startsWith('http') ? '_blank' : undefined}
key={`drawer-nav-item-${item.title}`}
onClick={handleOnClick(item)}
selected={selected}
>
<ListItemText primary={item.title} />
{isMenuLinkGroup(item) ? (
<ExpandLessIcon
sx={{
transform: `rotateZ(${open ? 0 : 90}deg)`,
transition: theme.transitions.create(['transform']),
}}
/>
) : null}
</ListItemButton>
),
[handleOnClick, item, open, selected, theme.transitions, url],
);
return (
<>
{wrappedLink}
{isMenuLinkGroup(item) ? (
<Collapse in={open} timeout="auto" unmountOnExit>
{item.groups.map(group => (
<List
key={group.title}
component="div"
subheader={
<ListSubheader
component="div"
id="nested-list-subheader"
sx={{
lineHeight: '32px',
textTransform: 'uppercase',
top: '-1px',
}}
>
{group.title}
</ListSubheader>
}
disablePadding
sx={{
marginTop: '8px',
'&:not(:first-of-type)': {
marginTop: '20px',
},
}}
>
{group.links.map(link => (
<MobileNavLink
key={`drawer-nav-item-${item.title}-sub-item-${link.title}`}
link={link}
onClick={handleOnClick(link)}
/>
))}
</List>
))}
</Collapse>
) : null}
</>
);
};
export default MobileNavItem;

View File

@ -0,0 +1,37 @@
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useMemo } from 'react';
import type { MouseEvent } from 'react';
import type { MenuLink } from '../../../interface';
interface MobileNavLinkProps {
link: MenuLink;
onClick: (event: MouseEvent) => void;
}
const MobileNavLink = ({ link, onClick }: MobileNavLinkProps) => {
const { title, url } = link;
const { asPath } = useRouter();
const selected = useMemo(() => {
return asPath === url;
}, [asPath, url]);
return (
<ListItemButton
component={Link}
href={url}
target={url.startsWith('http') ? '_blank' : undefined}
sx={{ paddingLeft: '24px', paddingTop: '4px', paddingBottom: '4px' }}
onClick={onClick}
selected={selected}
>
<ListItemText primary={title} />
</ListItemButton>
);
};
export default MobileNavLink;

View File

@ -0,0 +1,104 @@
import Divider from '@mui/material/Divider';
import List from '@mui/material/List';
import { styled, useTheme } from '@mui/material/styles';
import SwipeableDrawer from '@mui/material/SwipeableDrawer';
import { useMemo } from 'react';
import MobileNavItem from './MobileNavItem';
import Logo from '../Logo';
import type { MenuItem } from '../../../interface';
const DRAWER_WIDTH = 300;
const StyledDrawerContents = styled('div')`
text-align: center;
`;
const StyledLogoWrapper = styled('div')(
({ theme }) => `
display: flex;
justify-content: center;
padding: 16px 0;
background: ${
theme.palette.mode === 'light' ? theme.palette.primary.main : theme.palette.background.paper
};
`,
);
interface NavigationDrawerProps {
items: MenuItem[];
mobileOpen: boolean;
onMobileOpenToggle: () => void;
}
const NavigationDrawer = ({ items, mobileOpen, onMobileOpenToggle }: NavigationDrawerProps) => {
const theme = useTheme();
const iOS = useMemo(
() => typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent),
[],
);
const drawer = useMemo(
() => (
<StyledDrawerContents key="drawer-nav-contents" onClick={onMobileOpenToggle}>
<StyledLogoWrapper key="drawer-nav-logo-wrapper">
<Logo key="drawer-nav-logo" />
</StyledLogoWrapper>
<Divider key="drawer-nav-divider" sx={{ borderColor: 'rgba(255, 255, 255, 0.8)' }} />
<List key="drawer-nav-list">
{items.map(item => (
<MobileNavItem key={`drawer-nav-item-${item.title}`} item={item} />
))}
</List>
</StyledDrawerContents>
),
[items, onMobileOpenToggle],
);
const container = useMemo(
() => (typeof window !== 'undefined' ? window.document.body : undefined),
[],
);
return (
<SwipeableDrawer
key="swipable-drawer"
disableBackdropTransition={!iOS}
disableDiscovery={iOS}
container={container}
variant="temporary"
open={mobileOpen}
onOpen={onMobileOpenToggle}
onClose={onMobileOpenToggle}
ModalProps={{
keepMounted: true, // Better open performance on mobile.
}}
sx={{
display: 'none',
[theme.breakpoints.down('lg')]: {
display: 'block',
},
width: '80%',
maxWidth: DRAWER_WIDTH,
'& .MuiBackdrop-root': {
width: '100%',
},
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: '80%',
maxWidth: DRAWER_WIDTH,
background: theme.palette.background.paper,
},
'& .MuiListSubheader-root': {
textAlign: 'left',
},
}}
>
{drawer}
</SwipeableDrawer>
);
};
export default NavigationDrawer;

View File

@ -0,0 +1,85 @@
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 IconButton from '@mui/material/IconButton';
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;
}
`,
);
const StyledIconButton = styled(IconButton)(
({ theme }) => `
${theme.breakpoints.up('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>
<StyledIconButton onClick={handleOpen} color="inherit">
<SearchIcon />
</StyledIconButton>
<SearchModal open={open} onClose={handleClose} searchablePages={searchablePages} />
</>
);
};
export default Search;

View File

@ -0,0 +1,257 @@
import SearchIcon from '@mui/icons-material/Search';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import InputAdornment from '@mui/material/InputAdornment';
import { styled, useTheme } from '@mui/material/styles';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import useMediaQuery from '@mui/material/useMediaQuery';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useSearchScores } from '../../../util/search.util';
import { isNotEmpty } from '../../../util/string.util';
import SearchResult from './SearchResult';
import SuggestionLink from './SuggestionLink';
import type { ChangeEvent, FC } from 'react';
import type { SearchablePage } from '../../../interface';
const SEARCH_RESULTS_TO_SHOW = 5;
const StyledDialog = styled(Dialog)(
({ theme }) => `
${theme.breakpoints.between('md', 'lg')} {
& .MuiDialog-paper {
width: 60vw;
height: 60vh;
maxHeight: 600px;
}
}
${theme.breakpoints.up('lg')} {
& .MuiDialog-paper {
width: 600px;
height: 600px;
}
}
`,
);
const StyledDialogContent = styled(DialogContent)`
padding: 0;
`;
const StyledTextField = styled(TextField)`
height: 68px;
& .MuiInputBase-root {
padding: 16px;
}
& .MuiInputBase-root.MuiInput-root::after {
border-bottom: unset;
}
& .MuiInput-root {
font-size: 18px;
}
`;
const StyledSuggestions = styled('div')`
display: grid;
gap: 48px;
padding: 24px;
grid-template-columns: repeat(2, minmax(0, 2fr));
grid-template-rows: repeat(2, minmax(0, 2fr));
`;
const StyledSuggestionSection = styled('div')`
display: flex;
flex-direction: column;
gap: 4px;
`;
interface SearchModalProps {
open: boolean;
onClose: () => void;
searchablePages: SearchablePage[];
}
const SearchModal: FC<SearchModalProps> = ({ open, onClose, searchablePages }) => {
const inputRef = useRef<HTMLInputElement>();
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('md'));
const [canFocus, setCanFocus] = useState(true);
const [search, setSearch] = useState('');
const handleFocus = () => {
if (canFocus && open && inputRef.current) {
inputRef.current?.focus();
setSearch('');
setCanFocus(false);
}
};
const handleClose = useCallback(() => {
setCanFocus(true);
setTimeout(() => {
onClose();
});
}, [onClose]);
const handleSearchChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setSearch(event.target.value);
}, []);
const searchResults = useSearchScores(search, searchablePages);
const renderedResults = useMemo(
() =>
searchResults?.length > 0 ? (
[...Array<unknown>(SEARCH_RESULTS_TO_SHOW)].map((_, index) => {
if (searchResults.length <= index) {
return;
}
const result = searchResults[index];
const { entry } = result;
let summary = entry.textContent;
if (!result.isExactTitleMatch) {
const match = new RegExp(
`(?:[\\s]+[^\\s]+){0,10}[\\s]*${search}(?![^<>]*(([/"']|]]|\\b)>))[\\s]*(?:[^\\s]+\\s){0,25}`,
'ig',
).exec(entry.textContent);
if (match && match.length >= 1) {
summary = `...${match[0].trim()}...`;
} else {
const match = new RegExp(
`(?:[\\s]+[^\\s]+){0,10}[\\s]*(${search
.split(' ')
.join('|')})(?![^<>]*(([/"']|]]|\\b)>))[\\s]*(?:[^\\s]+\\s){0,25}`,
'ig',
).exec(entry.textContent);
if (match && match.length >= 1) {
summary = `...${match[0].trim()}...`;
}
}
}
summary = summary?.replace(
new RegExp(`(${search.split(' ').join('|')})(?![^<>]*(([/"']|]]|\\b)>))`, 'ig'),
`<strong style="color: ${theme.palette.primary.main}">$1</strong>`,
);
return (
<SearchResult
key={`result-${entry.url}`}
entry={entry}
summary={summary}
onClick={handleClose}
/>
);
})
) : isNotEmpty(search) ? (
<Typography
variant="h3"
component="div"
key="no-results"
sx={{ width: '100%', textAlign: 'center', marginTop: '16px' }}
>
No results found
</Typography>
) : (
<StyledSuggestions>
<StyledSuggestionSection>
<Typography variant="h3" sx={{ marginBottom: '4px' }}>
Getting Started
</Typography>
<SuggestionLink href="/docs/start-with-a-template">
Start With a Template
</SuggestionLink>
<SuggestionLink href="/docs/add-to-your-site">Add to Your Site</SuggestionLink>
<SuggestionLink href="/docs/configuration-options">
Configuration Options
</SuggestionLink>
<SuggestionLink href="/docs/collection-overview">Collections</SuggestionLink>
</StyledSuggestionSection>
<StyledSuggestionSection>
<Typography variant="h3" sx={{ marginBottom: '4px' }}>
Backends
</Typography>
<SuggestionLink href="/docs/github-backend">GitHub</SuggestionLink>
<SuggestionLink href="/docs/bitbucket-backend">Bitbucket</SuggestionLink>
<SuggestionLink href="/docs/gitlab-backend">GitLab</SuggestionLink>
</StyledSuggestionSection>
<StyledSuggestionSection>
<Typography variant="h3" sx={{ marginBottom: '4px' }}>
Platform Guides
</Typography>
<SuggestionLink href="/docs/nextjs">Next</SuggestionLink>
<SuggestionLink href="/docs/gatsby">Gatsby</SuggestionLink>
<SuggestionLink href="/docs/jekyll">Jekyll</SuggestionLink>
<SuggestionLink href="/docs/hugo">Hugo</SuggestionLink>
</StyledSuggestionSection>
<StyledSuggestionSection>
<Typography variant="h3" sx={{ marginBottom: '4px' }}>
Widgets
</Typography>
<SuggestionLink href="/docs/widget-string">String</SuggestionLink>
<SuggestionLink href="/docs/widget-image">Image</SuggestionLink>
<SuggestionLink href="/docs/widget-datetime">Datetime</SuggestionLink>
<SuggestionLink href="/docs/widget-markdown">Markdown</SuggestionLink>
</StyledSuggestionSection>
</StyledSuggestions>
),
[handleClose, search, searchResults, theme.palette.primary.main],
);
return (
<StyledDialog
open={open}
onClose={handleClose}
fullScreen={fullScreen}
fullWidth
onFocus={handleFocus}
>
<StyledDialogContent>
<StyledTextField
autoFocus
id="search"
placeholder="Search"
fullWidth
variant="standard"
value={search}
onChange={handleSearchChange}
InputProps={{
inputRef,
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<Button
size="small"
variant="outlined"
color="inherit"
sx={{ p: '0 6px', m: 0, width: 'auto', minWidth: 'unset' }}
onClick={onClose}
>
ESC
</Button>
</InputAdornment>
),
}}
/>
<div>{renderedResults}</div>
</StyledDialogContent>
</StyledDialog>
);
};
export default SearchModal;

View File

@ -0,0 +1,117 @@
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 (
<Button
component={Link}
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>
);
};
export default SearchResult;

View File

@ -0,0 +1,36 @@
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import MuiLink from '@mui/material/Link';
import Link from 'next/link';
import type { FC } from 'react';
interface SuggestionLinkProps {
href: string;
children: string;
}
const SuggestionLink: FC<SuggestionLinkProps> = ({ href, children }) => {
return (
<MuiLink
component={Link}
href={href}
color="primary"
sx={{
textDecoration: 'none',
display: 'flex',
paddingLeft: '8px',
transition: 'gap 0.1s ease-in-out',
gap: 0,
'&:hover': {
textDecoration: 'underline',
gap: '4px',
},
}}
>
{children}
<ChevronRightIcon fontSize="small" sx={{ marginTop: '1px', marginLeft: '2px' }} />
</MuiLink>
);
};
export default SuggestionLink;

View File

@ -0,0 +1,26 @@
import Head from 'next/head';
import config from '../../lib/config';
interface BasicMetaProps {
title?: string;
description?: string;
keywords?: string[];
url: string;
}
const BasicMeta = ({ title, description, keywords, url }: BasicMetaProps) => {
return (
<Head>
<title>{title ? [title, config.site_title].join(' | ') : config.site_title}</title>
<meta name="description" content={description ? description : config.site_description} />
<meta
name="keywords"
content={keywords ? keywords.join(',') : config.site_keywords.join(',')}
/>
<link rel="canonical" href={config.base_url.replace(/\/$/g, '') + url} />
</Head>
);
};
export default BasicMeta;

View File

@ -0,0 +1,37 @@
import formatISO from 'date-fns/formatISO';
import Head from 'next/head';
import { jsonLdScriptProps } from 'react-schemaorg';
import config from '../../lib/config';
import type { BlogPosting } from 'schema-dts';
interface JsonLdMetaProps {
url: string;
title?: string;
keywords?: string[];
date?: Date;
image?: string;
description?: string;
}
const JsonLdMeta = ({ url, title, keywords, date, image, description }: JsonLdMetaProps) => {
return (
<Head>
<script
{...jsonLdScriptProps<BlogPosting>({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
mainEntityOfPage: config.base_url.replace(/\/$/g, '') + url,
headline: title ?? config.site_title,
keywords: keywords ? keywords.join(',') : undefined,
datePublished: date ? formatISO(date) : undefined,
image,
description,
})}
/>
</Head>
);
};
export default JsonLdMeta;

View File

@ -0,0 +1,35 @@
import Head from 'next/head';
import { useMemo } from 'react';
import config from '../../lib/config';
interface OpenGraphMetaProps {
url: string;
title?: string;
description?: string;
image?: string;
}
const OpenGraphMeta = ({ url, title, description, image }: OpenGraphMetaProps) => {
const imageUrl = useMemo(() => {
return image
? `${config.base_url.replace(/\/$/g, '')}/${image.replace(/^\//g, '')}`
: `${config.base_url.replace(/\/$/g, '')}/${config.site_image.replace(/^\//g, '')}`;
}, [image]);
return (
<Head>
<meta property="og:site_name" content={config.site_title} />
<meta property="og:url" content={config.base_url.replace(/\/$/g, '') + url} />
<meta property="og:title" content={title ? [title, config.site_title].join(' | ') : ''} />
<meta
property="og:description"
content={description ? description : config.site_description}
/>
<meta property="og:image" content={imageUrl} />
<meta property="og:type" content="article" />
</Head>
);
};
export default OpenGraphMeta;

View File

@ -0,0 +1,37 @@
import Head from 'next/head';
import { useMemo } from 'react';
import config from '../../lib/config';
interface TwitterCardMetaProps {
url: string;
title?: string;
description?: string;
image?: string;
}
const TwitterCardMeta = ({ url, title, description, image }: TwitterCardMetaProps) => {
const imageUrl = useMemo(() => {
return image
? `${config.base_url.replace(/\/$/g, '')}/${image.replace(/^\//g, '')}`
: `${config.base_url.replace(/\/$/g, '')}/${config.site_image.replace(/^\//g, '')}`;
}, [image]);
return (
<Head>
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={config.base_url.replace(/\/$/g, '') + url} />
<meta
property="twitter:title"
content={title ? [title, config.site_title].join(' | ') : config.site_title}
/>
<meta
property="twitter:description"
content={description ? description : config.site_description}
/>
<meta property="twitter:image" content={imageUrl} />
</Head>
);
};
export default TwitterCardMeta;

View File

@ -0,0 +1,3 @@
export const SEARCH_RESULTS_TO_SHOW = 10;
export const SUMMARY_MIN_PARAGRAPH_LENGTH = 150;

View File

@ -0,0 +1,152 @@
import type { GrayMatterFile } from 'gray-matter';
export interface FileMatter {
readonly fileName: string;
readonly fullPath: string;
readonly matterResult: GrayMatterFile<string>;
}
export interface FooterLink {
readonly text: string;
readonly url: string;
}
export interface SiteConfig {
readonly base_url: string;
readonly repo_url: string;
readonly site_title: string;
readonly site_description: string;
readonly site_image: string;
readonly site_keywords: string[];
readonly footer: {
buttons: FooterLink[];
links: FooterLink[];
};
}
export interface Overview {
title: string;
description: string;
}
export interface GetStarted {
readonly title: string;
readonly url: string;
}
export interface CallToAction {
readonly title: string;
readonly subtitle: string;
readonly button_text: string;
readonly url: string;
}
export interface FeatureIntro {
title: string;
subtitle1: string;
subtitle2: string;
}
export interface Feature {
title: string;
description: string;
image: string;
}
export interface HomepageData {
readonly title: string;
readonly subtitle: string;
readonly get_started: GetStarted;
readonly overviews: Overview[];
readonly features_intro: FeatureIntro;
readonly features: Feature[];
readonly call_to_action: CallToAction;
}
export interface Release {
readonly date: string;
readonly version: string;
readonly description: string;
}
export interface DocsData {
readonly group: string;
readonly title: string;
readonly weight: number;
readonly slug: string;
}
export interface DocsPageHeading {
readonly title: string;
readonly anchor: string;
}
export interface DocsPage {
readonly fullPath: string;
readonly headings: DocsPageHeading[];
readonly textContent: string;
readonly content: string;
readonly data: DocsData;
}
export interface DocsGroupLink {
readonly title: string;
readonly slug: string;
}
export interface DocsGroup {
readonly name: string;
readonly title: string;
readonly links: DocsGroupLink[];
}
export interface MenuGroup {
readonly name: string;
readonly title: string;
}
export interface Menu {
readonly docs: MenuGroup[];
}
export interface CommunityLink {
readonly title: string;
readonly description: string;
readonly url: string;
}
export interface CommunityLinksSection {
readonly title: string;
readonly links: CommunityLink[];
}
export interface CommunityData {
readonly title: string;
readonly subtitle: string;
readonly sections: CommunityLinksSection[];
}
export interface MenuLink {
readonly title: string;
readonly url: string;
}
export interface MenuLinkSubGroup {
readonly title: string;
readonly links: MenuLink[];
}
export interface MenuLinkGroup {
readonly title: string;
readonly path: string;
readonly groups: MenuLinkSubGroup[];
}
export type MenuItem = MenuLinkGroup | MenuLink;
export interface SearchablePage {
readonly title: string;
readonly url: string;
readonly headings: DocsPageHeading[];
readonly textContent: string;
}

View File

@ -0,0 +1,5 @@
import data from '../../content/community.json';
import type { CommunityData } from '../interface';
export default data as CommunityData;

View File

@ -0,0 +1,5 @@
import config from '../../content/config.json';
import type { SiteConfig } from '../interface';
export default config as SiteConfig;

View File

@ -0,0 +1,179 @@
import fs from 'fs';
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,
SearchablePage,
} from '../interface';
export interface SearchProps {
searchablePages: SearchablePage[];
}
export interface DocsMenuProps extends SearchProps {
docsGroups: DocsGroup[];
}
const docsDirectory = path.join(process.cwd(), 'content/docs');
let docsMatterCache: FileMatter[];
let docsCache: [DocsPage[], DocsGroup[]];
export function fetchDocsMatter(): FileMatter[] {
if (docsMatterCache && process.env.NODE_ENV !== 'development') {
return docsMatterCache;
}
// Get file names under /docs
const fileNames = fs.readdirSync(docsDirectory);
const allDocsMatter = fileNames
.filter(it => it.endsWith('.mdx'))
.map(fileName => {
// Read file as string
const fullPath = path.join(docsDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, 'utf8');
// Use gray-matter to parse the doc metadata section
const matterResult = matter(fileContents, {
engines: {
yaml: s => yaml.load(s, { schema: yaml.JSON_SCHEMA }) as object,
},
});
return { fileName, fullPath, matterResult };
});
// Sort docs by date
docsMatterCache = allDocsMatter.sort(
(a, b) => a.matterResult.data.weight - b.matterResult.data.weight,
);
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;
}
const allDocsData: DocsPage[] = fetchDocsMatter().map(
({ fileName, fullPath, matterResult: { data, content } }) => {
const slug = fileName.replace(/\.mdx$/, '');
const summaryRegex = /^<p>([\w\W]+?)<\/p>/i;
let summaryMatch = summaryRegex.exec(content);
const htmlSummaryRegex =
/^([\s\n]*(?:<(?:p|ul|ol|h1|h2|h3|h4|h5|h6|div)>(?:[\s\S])*?<\/(?:p|ul|ol|h1|h2|h3|h4|h5|h6|div)>[\s\n]*){1,2})/i;
if (
!summaryMatch ||
summaryMatch.length < 2 ||
summaryMatch[1].length < SUMMARY_MIN_PARAGRAPH_LENGTH
) {
summaryMatch = htmlSummaryRegex.exec(content);
}
return {
fullPath,
data: {
...data,
slug,
} as DocsData,
textContent: getTextContent(content),
headings: getHeadings(content).map(heading => ({
title: heading,
anchor: getAnchor(heading),
})),
content,
};
},
);
const pagesByGroup: Record<string, DocsGroupLink[]> = allDocsData.reduce((acc, doc) => {
if (!(doc.data.group in acc)) {
acc[doc.data.group] = [];
}
acc[doc.data.group].push({
title: doc.data.title,
slug: doc.data.slug,
});
return acc;
}, {} as Record<string, DocsGroupLink[]>);
const docsGroups: DocsGroup[] = menu.docs.map(group => ({
...group,
links: pagesByGroup[group.name] ?? [],
}));
docsCache = [allDocsData, docsGroups];
return docsCache;
}
export function getSearchablePages(): SearchablePage[] {
const pages = fetchDocsContent()[0];
return pages.map(page => ({
title: page.data.title,
textContent: page.textContent,
url: `/docs/${page.data.slug}`,
headings: page.headings,
}));
}
export const getSearchStaticProps: GetStaticProps = (): { props: SearchProps } => {
return {
props: {
searchablePages: getSearchablePages(),
},
};
};
export const getDocsMenuStaticProps: GetStaticProps = (): { props: DocsMenuProps } => {
return {
props: {
docsGroups: fetchDocsContent()[1],
searchablePages: getSearchablePages(),
},
};
};

View File

@ -0,0 +1,5 @@
import data from '../../content/homepage.json';
import type { HomepageData } from '../interface';
export default data as HomepageData;

View File

@ -0,0 +1,5 @@
import data from '../../content/menu.json';
import type { Menu } from '../interface';
export default data.menu as Menu;

View File

@ -0,0 +1,5 @@
import data from '../../content/releases.json';
import type { Release } from '../interface';
export default data.releases as Release[];

View File

@ -0,0 +1,82 @@
import '../styles/globals.css';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import Head from 'next/head';
import { useRouter } from 'next/router';
import Prism from 'prismjs';
import { useEffect, useMemo, useState } from 'react';
import ColorModeContext from '../components/context/ColorModeContext';
import homepageData from '../lib/homepage';
import useCreateTheme from '../styles/theme';
import type { PaletteMode } from '@mui/material';
import type { AppProps } from 'next/app';
require('prismjs/components/prism-javascript');
require('prismjs/components/prism-typescript');
require('prismjs/components/prism-css');
require('prismjs/components/prism-jsx');
require('prismjs/components/prism-tsx');
require('prismjs/components/prism-yaml');
require('prismjs/components/prism-json');
require('prismjs/components/prism-toml');
require('prismjs/components/prism-markup-templating');
require('prismjs/components/prism-handlebars');
require('prismjs/components/prism-markdown');
function MyApp({ Component, pageProps }: AppProps) {
const [mode, setMode] = useState<PaletteMode>('dark');
const colorMode = useMemo(
() => ({
// The dark mode switch would invoke this method
toggleColorMode: () => {
const newMode = mode === 'light' ? 'dark' : 'light';
setMode(newMode);
localStorage.setItem('palette-mode', newMode);
},
}),
[mode],
);
useEffect(() => {
setMode(localStorage?.getItem('palette-mode') === 'light' ? 'light' : 'dark');
}, []);
const { asPath } = useRouter();
useEffect(() => {
Prism.highlightAll();
}, [asPath]);
// Update the theme only if the mode changes
const theme = useCreateTheme(mode);
return (
<>
<Head>
<meta charSet="utf-8" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32x32.png" />
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no, viewport-fit=cover"
/>
<meta name="theme-color" content="#2e3034" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content={homepageData.title} />
</Head>
<ColorModeContext.Provider value={colorMode}>
<ThemeProvider theme={theme}>
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
</ColorModeContext.Provider>
</>
);
}
export default MyApp;

View File

@ -0,0 +1,13 @@
import { Head, Html, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}

View File

@ -0,0 +1,105 @@
import { styled } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import CommunitySection from '../components/community/CommunitySection';
import Container from '../components/layout/Container';
import Page from '../components/layout/Page';
import communityData from '../lib/community';
import { getDocsMenuStaticProps } from '../lib/docs';
import type { DocsMenuProps } from '../lib/docs';
const StyledCommunityContent = styled('div')(
({ theme }) => `
width: 100%;
padding-top: 72px;
min-height: calc(100vh - 72px);
display: flex;
flex-direction: column;
gap: 80px;
${theme.breakpoints.between('md', 'lg')} {
padding-top: 48px;
gap: 56px
}
${theme.breakpoints.down('md')} {
padding-top: 32px;
gap: 40px
}
`,
);
const StyledTitle = styled('div')(
({ theme }) => `
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
${theme.breakpoints.down('lg')} {
gap: 8px
}
`,
);
const StyledCommunityLinks = styled('section')(
({ theme }) => `
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
background: ${theme.palette.mode === 'light' ? '#dddee2' : '#242424'};
padding: 64px 0 32px;
flex-grow: 1;
${theme.breakpoints.between('md', 'lg')} {
padding: 48px 0 32px;
}
${theme.breakpoints.down('md')} {
padding: 40px 0 32px;
}
`,
);
const StyledCommunityLinksContent = styled('div')`
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 40px;
`;
const Community = ({ docsGroups, searchablePages }: DocsMenuProps) => {
return (
<Page url="/community" docsGroups={docsGroups} searchablePages={searchablePages} fullWidth>
<StyledCommunityContent>
<Container>
<StyledTitle>
<Typography variant="h1" color="primary">
{communityData.title}
</Typography>
<Typography variant="h2" color="text.primary">
{communityData.subtitle}
</Typography>
</StyledTitle>
</Container>
<StyledCommunityLinks>
<Container>
<StyledCommunityLinksContent>
{communityData.sections.map(section => (
<CommunitySection key={section.title} section={section} />
))}
</StyledCommunityLinksContent>
</Container>
</StyledCommunityLinks>
</StyledCommunityContent>
</Page>
);
};
export default Community;
export const getStaticProps = getDocsMenuStaticProps;

View File

@ -0,0 +1,179 @@
import Alert from '@mui/material/Alert';
import { styled, useTheme } from '@mui/material/styles';
import { MDXRemote } from 'next-mdx-remote';
import { serialize } from 'next-mdx-remote/serialize';
import remarkGfm from 'remark-gfm';
import Anchor from '../../components/docs/components/Anchor';
import Blockquote from '../../components/docs/components/Blockquote';
import CodeTabs from '../../components/docs/components/CodeTabs';
import Header1 from '../../components/docs/components/headers/Header1';
import Header2 from '../../components/docs/components/headers/Header2';
import Header3 from '../../components/docs/components/headers/Header3';
import Header4 from '../../components/docs/components/headers/Header4';
import Header5 from '../../components/docs/components/headers/Header5';
import Header6 from '../../components/docs/components/headers/Header6';
import DocsTable from '../../components/docs/components/table/Table';
import TableBody from '../../components/docs/components/table/TableBody';
import TableBodyCell from '../../components/docs/components/table/TableBodyCell';
import TableHead from '../../components/docs/components/table/TableHead';
import TableHeaderCell from '../../components/docs/components/table/TableHeaderCell';
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, getSearchablePages } from '../../lib/docs';
import type { MDXRemoteSerializeResult } from 'next-mdx-remote';
import type { GetStaticPaths, GetStaticProps } from 'next/types';
import type { DocsGroup, DocsPage, SearchablePage } from '../../interface';
const StyledDocsView = styled('div')(
({ theme }) => `
display: grid;
grid-template-columns: calc(100% - 240px) 240px;
margin-left: 280px;
width: calc(100% - 280px);
padding-top: 16px;
${theme.breakpoints.down('lg')} {
margin-left: 0;
padding-top: 24px;
width: 100vw;
}
${theme.breakpoints.down('md')} {
grid-template-columns: 1fr;
}
`,
);
const StyledDocsContentWrapper = styled('main')(
({ theme }) => `
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
margin: 0;
margin-bottom: 40px;
${theme.breakpoints.between('md', 'lg')} {
width: calc(100vw - 250px);
}
${theme.breakpoints.down('lg')} {
margin-bottom: 32px;
}
${theme.breakpoints.down('md')} {
width: 100vw;
}
`,
);
interface DocsProps {
docsGroups: DocsGroup[];
searchablePages: SearchablePage[];
title: string;
slug: string;
description?: string;
source: MDXRemoteSerializeResult;
}
const Docs = ({
docsGroups,
searchablePages,
title,
slug,
description = '',
source,
}: DocsProps) => {
const theme = useTheme();
return (
<Page
title={title}
url={`/docs/${slug}`}
description={description}
docsGroups={docsGroups}
searchablePages={searchablePages}
fullWidth
>
<DocsLeftNav docsGroups={docsGroups} />
<StyledDocsView className={theme.palette.mode}>
<StyledDocsContentWrapper>
<DocsContent>
<Header1>{title}</Header1>
<MDXRemote
{...source}
components={{
h1: Header1,
h2: Header2,
h3: Header3,
h4: Header4,
h5: Header5,
h6: Header6,
blockquote: Blockquote,
table: DocsTable,
thead: TableHead,
tbody: TableBody,
th: TableHeaderCell,
td: TableBodyCell,
a: Anchor,
CodeTabs,
Alert,
}}
/>
</DocsContent>
</StyledDocsContentWrapper>
<DocsRightNav />
</StyledDocsView>
</Page>
);
};
export default Docs;
export const getStaticPaths: GetStaticPaths = async () => {
const paths = fetchDocsContent()[0].map(docs => `/docs/${docs.data.slug}`);
return {
paths,
fallback: false,
};
};
const buildSlugToDocsContent = (docsContents: DocsPage[]) => {
const hash: Record<string, DocsPage> = {};
docsContents.forEach(docs => (hash[docs.data.slug] = docs));
return hash;
};
let slugToDocsContent = buildSlugToDocsContent(fetchDocsContent()[0]);
let docsGroups = fetchDocsContent()[1];
export const getStaticProps: GetStaticProps = async ({ params }): Promise<{ props: DocsProps }> => {
const slug = params?.doc as string;
if (process.env.NODE_ENV === 'development') {
slugToDocsContent = buildSlugToDocsContent(fetchDocsContent()[0]);
docsGroups = fetchDocsContent()[1];
}
const { content, data } = slugToDocsContent[slug];
const source = await serialize(content, {
mdxOptions: {
remarkPlugins: [remarkGfm],
},
});
return {
props: {
docsGroups,
searchablePages: getSearchablePages(),
title: data.title,
slug: data.slug,
description: '',
source,
},
};
};

View File

@ -0,0 +1,451 @@
import Button from '@mui/material/Button';
import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import CardContent from '@mui/material/CardContent';
import Chip from '@mui/material/Chip';
import { styled, useTheme } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import Image from 'next/image';
import Link from 'next/link';
import DateDisplay from '../components/DateDisplay';
import Container from '../components/layout/Container';
import Page from '../components/layout/Page';
import config from '../lib/config';
import { getDocsMenuStaticProps } from '../lib/docs';
import homepageData from '../lib/homepage';
import releases from '../lib/releases';
import type { DocsMenuProps } from '../lib/docs';
const StyledHomagePageContent = styled('div')(
({ theme }) => `
width: 100%;
padding-top: 72px;
display: flex;
flex-direction: column;
gap: 88px;
align-items: center;
${theme.breakpoints.down('md')} {
padding-top: 32px;
gap: 0;
}
`,
);
const StyledIntroSection = styled('section')`
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
`;
const StyledIntroSectionContent = styled('div')`
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
align-items: flex-start;
`;
const StyledOverviewSection = styled('section')(
({ theme }) => `
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
${theme.breakpoints.down('md')} {
margin-top: 64px;
}
`,
);
const StyledOverviewSectionContent = styled('div')(
({ theme }) => `
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 64px;
${theme.breakpoints.down('md')} {
grid-template-columns: 1fr;
gap: 24px;
}
`,
);
const StyledOverviewList = styled('div')`
display: flex;
flex-direction: column;
gap: 16px;
`;
const StyledOverview = styled('div')`
display: flex;
flex-direction: column;
gap: 8px;
`;
const StyledImageWrapper = styled('div')`
width: 100%;
position: relative;
`;
const StyledCallToActionSection = styled('section')(
({ theme }) => `
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
height: 0;
overflow: visible;
z-index: 1;
${theme.breakpoints.down('md')} {
height: auto;
margin-top: 64px;
}
`,
);
const StyledCallToActionContainer = styled('div')(
({ theme }) => `
max-width: 1280px;
width: 100%;
padding: 0 40px;
display: flex;
flex-direction: column;
align-items: center;
${theme.breakpoints.down('md')} {
padding: 0;
}
`,
);
const StyledCallToActionCard = styled(Card)(
({ theme }) => `
width: 80%;
${theme.breakpoints.down('md')} {
width: 100%;
}
`,
);
const StyledCallToActionCardContent = styled(CardContent)(
({ theme }) => `
display: flex;
align-items: flex-start;
padding: 24px 40px;
line-height: 30px;
gap: 24px;
${theme.breakpoints.down('md')} {
flex-direction: column;
}
`,
);
const StyledCallToActionText = styled('div')`
flex-grow: 1;
`;
const StyledReleasesSection = styled('section')(
({ theme }) => `
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
background: ${theme.palette.mode === 'light' ? '#dddee2' : '#242424'};
padding: 64px 0;
${theme.breakpoints.down('md')} {
padding: 48px 0;
}
`,
);
const StyledReleasesSectionContent = styled('div')(
({ theme }) => `
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 48px;
${theme.breakpoints.down('md')} {
grid-template-columns: 1fr;
}
`,
);
const StyledReleaseCardContent = styled(CardContent)`
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
`;
const StyledFeaturesSection = styled('section')(
({ theme }) => `
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 80px;
${theme.breakpoints.down('md')} {
height: auto;
margin-top: 48px;
}
`,
);
const StyledFeaturesSectionIntro = styled('div')(
({ theme }) => `
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 32px 0 104px;
${theme.breakpoints.down('md')} {
padding: 32px 0 48px;
}
`,
);
const StyledFeaturesSectionContent = styled('div')(
({ theme }) => `
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 48px;
${theme.breakpoints.down('md')} {
grid-template-columns: 1fr;
}
`,
);
const StyledFeature = styled('div')`
width: 100%;
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
`;
const StyledFeatureText = styled('div')`
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 16px;
`;
const Home = ({ docsGroups, searchablePages }: DocsMenuProps) => {
const theme = useTheme();
return (
<Page url="/" docsGroups={docsGroups} searchablePages={searchablePages} fullWidth>
<StyledHomagePageContent>
<StyledIntroSection>
<Container>
<StyledIntroSectionContent>
<Typography variant="h1" color="primary">
{homepageData.title}
</Typography>
<Typography variant="h2" color="text.primary">
{homepageData.subtitle}
</Typography>
<Button
component={Link}
href={homepageData.get_started.url}
variant="contained"
size="large"
>
{homepageData.get_started.title}
</Button>
</StyledIntroSectionContent>
</Container>
</StyledIntroSection>
<StyledOverviewSection>
<Container>
<StyledOverviewSectionContent>
<StyledOverviewList>
{homepageData.overviews.map(overview => (
<StyledOverview key={overview.title}>
<Typography variant="h3" color="text.primary">
{overview.title}
</Typography>
<Typography variant="body1" color="text.secondary">
{overview.description}
</Typography>
</StyledOverview>
))}
</StyledOverviewList>
<StyledImageWrapper>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/img/screenshot-editor.webp" />
</StyledImageWrapper>
</StyledOverviewSectionContent>
</Container>
</StyledOverviewSection>
<StyledCallToActionSection>
<StyledCallToActionContainer>
<StyledCallToActionCard raised>
<StyledCallToActionCardContent>
<StyledCallToActionText>
<Typography variant="subtitle1" color="text.primary" component="strong">
{homepageData.call_to_action.title}
</Typography>
&nbsp;
<Typography variant="body1" color="text.secondary" component="span">
{homepageData.call_to_action.subtitle}
</Typography>
</StyledCallToActionText>
<Button
component={Link}
href={homepageData.call_to_action.url}
variant="contained"
size="large"
sx={{ width: '188px', whiteSpace: 'nowrap' }}
>
{homepageData.call_to_action.button_text}
</Button>
</StyledCallToActionCardContent>
</StyledCallToActionCard>
</StyledCallToActionContainer>
</StyledCallToActionSection>
<StyledReleasesSection>
<Container>
<StyledReleasesSectionContent>
{[...Array(3)].map((_, index) => {
if (index >= releases.length) {
return null;
}
const release = releases[index];
return (
<CardActionArea
key={release.version}
href={`${config.repo_url}/releases/tag/${release.version}`}
>
<StyledReleaseCardContent>
<Typography
variant="subtitle1"
color="text.primary"
sx={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
<>
<Chip label={release.version} color="primary" />
<DateDisplay date={release.date} format="MMMM dd, yyyy" />
</>
</Typography>
<Typography variant="body2" color="text.secondary">
{release.description}
</Typography>
</StyledReleaseCardContent>
</CardActionArea>
);
})}
</StyledReleasesSectionContent>
</Container>
</StyledReleasesSection>
<StyledFeaturesSection>
<Container>
<StyledFeaturesSectionIntro>
<Typography
variant="h2"
color="text.primary"
sx={{
display: 'flex',
alignItems: 'center',
gap: '8px',
[theme.breakpoints.down('md')]: {
textAlign: 'center',
},
}}
>
{homepageData.features_intro.title}
</Typography>
<Typography
variant="subtitle1"
component="div"
color="text.secondary"
sx={{
textAlign: 'center',
[theme.breakpoints.down('md')]: {
textAlign: 'center',
marginTop: '24px',
},
}}
>
{homepageData.features_intro.subtitle1} {homepageData.features_intro.subtitle2}
</Typography>
</StyledFeaturesSectionIntro>
<StyledFeaturesSectionContent>
{homepageData.features.map(feature => (
<StyledFeature key={feature.title}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={feature.image} width="100%" height="auto" />
<StyledFeatureText>
<Typography
variant="subtitle1"
color="text.primary"
sx={{ display: 'flex', alignItems: 'center', gap: '8px' }}
>
{feature.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{feature.description}
</Typography>
</StyledFeatureText>
</StyledFeature>
))}
</StyledFeaturesSectionContent>
</Container>
</StyledFeaturesSection>
<footer>
{theme.palette.mode === 'light' ? (
<a
key="netlify-logo-light"
href="https://www.netlify.com"
target="_blank"
rel="noreferrer"
>
<Image
width={114}
height={51}
src="/img/netlify-color-bg.svg"
alt="Deploys by Netlify"
/>
</a>
) : (
<a
key="netlify-logo-dark"
href="https://www.netlify.com"
target="_blank"
rel="noreferrer"
>
<Image
width={114}
height={51}
src="/img/netlify-color-accent.svg"
alt="Deploys by Netlify"
/>
</a>
)}
</footer>
</StyledHomagePageContent>
</Page>
);
};
export default Home;
export const getStaticProps = getDocsMenuStaticProps;

View File

@ -0,0 +1,229 @@
* {
box-sizing: border-box;
}
img {
max-width: 100%;
}
#__next {
position: relative;
}
/* PrismJS 1.29.0 (Dark)
https://prismjs.com/download.html#themes=prism-tomorrow&languages=markup+css+clike+javascript+json+toml+typescript+yaml */
.dark code[class*='language-'],
.dark pre[class*='language-'] {
color: #ccc;
background: 0 0;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
.dark pre[class*='language-'] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
}
.dark :not(pre) > code[class*='language-'],
.dark pre[class*='language-'] {
background: #2d2d2d;
}
.dark :not(pre) > code[class*='language-'] {
padding: 0.1em;
border-radius: 0.3em;
white-space: normal;
}
.dark .token.block-comment,
.dark .token.cdata,
.dark .token.comment,
.dark .token.doctype,
.dark .token.prolog {
color: #999;
}
.dark .token.punctuation {
color: #ccc;
}
.dark .token.attr-name,
.dark .token.deleted,
.dark .token.namespace,
.dark .token.tag {
color: #e2777a;
}
.dark .token.function-name {
color: #6196cc;
}
.dark .token.boolean,
.dark .token.function,
.dark .token.number {
color: #f08d49;
}
.dark .token.class-name,
.dark .token.constant,
.dark .token.property,
.dark .token.symbol {
color: #f8c555;
}
.dark .token.atrule,
.dark .token.builtin,
.dark .token.important,
.dark .token.keyword,
.dark .token.selector {
color: #cc99cd;
}
.dark .token.attr-value,
.dark .token.char,
.dark .token.regex,
.dark .token.string,
.dark .token.variable {
color: #7ec699;
}
.dark .token.entity,
.dark .token.operator,
.dark .token.url {
color: #67cdcc;
}
.dark .token.bold,
.dark .token.important {
font-weight: 700;
}
.dark .token.italic {
font-style: italic;
}
.dark .token.entity {
cursor: help;
}
.dark .token.inserted {
color: green;
}
/* PrismJS 1.29.0 (Light)
https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript+json+toml+typescript+yaml */
.light code[class*='language-'],
.light pre[class*='language-'] {
color: #000;
background: 0 0;
text-shadow: 0 1px #fff;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
.light code[class*='language-'] ::-moz-selection,
.light code[class*='language-']::-moz-selection,
.light pre[class*='language-'] ::-moz-selection,
.light pre[class*='language-']::-moz-selection {
text-shadow: none;
background: #b3d4fc;
}
.light code[class*='language-'] ::selection,
.light code[class*='language-']::selection,
.light pre[class*='language-'] ::selection,
.light pre[class*='language-']::selection {
text-shadow: none;
background: #b3d4fc;
}
@media print {
.light code[class*='language-'],
.light pre[class*='language-'] {
text-shadow: none;
}
}
.light pre[class*='language-'] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
}
.light :not(pre) > code[class*='language-'],
.light pre[class*='language-'] {
background: #f5f2f0;
}
.light :not(pre) > code[class*='language-'] {
padding: 0.1em;
border-radius: 0.3em;
white-space: normal;
}
.light .token.cdata,
.light .token.comment,
.light .token.doctype,
.light .token.prolog {
color: #708090;
}
.light .token.punctuation {
color: #999;
}
.light .token.namespace {
opacity: 0.7;
}
.light .token.boolean,
.light .token.constant,
.light .token.deleted,
.light .token.number,
.light .token.property,
.light .token.symbol,
.light .token.tag {
color: #905;
}
.light .token.attr-name,
.light .token.builtin,
.light .token.char,
.light .token.inserted,
.light .token.selector,
.light .token.string {
color: #690;
}
.light .language-css .token.string,
.light .style .token.string,
.light .token.entity,
.light .token.operator,
.light .token.url {
color: #9a6e3a;
background: hsla(0, 0%, 100%, 0.5);
}
.light .token.atrule,
.light .token.attr-value,
.light .token.keyword {
color: #07a;
}
.light .token.class-name,
.light .token.function {
color: #dd4a68;
}
.light .token.important,
.light .token.regex,
.light .token.variable {
color: #e90;
}
.light .token.bold,
.light .token.important {
font-weight: 700;
}
.light .token.italic {
font-style: italic;
}
.light .token.entity {
cursor: help;
}

View File

@ -0,0 +1,94 @@
import darkScrollbar from '@mui/material/darkScrollbar';
import { createTheme } from '@mui/material/styles';
import { useMemo } from 'react';
import type { Components, PaletteMode, PaletteOptions, Theme } from '@mui/material';
const useCreateTheme = (mode: PaletteMode) => {
const theme = useMemo(() => createTheme(), []);
const palette: PaletteOptions = useMemo(
() =>
mode === 'light'
? {
mode,
primary: {
main: '#1b72de',
},
secondary: {
main: '#1b72de',
},
background: {
default: '#f9f9f9',
paper: '#f9f9f9',
},
}
: {
mode,
primary: {
main: '#5ecffb',
},
secondary: {
main: '#5ecffb',
},
background: {
default: '#2e3034',
paper: '#2e3034',
},
},
[mode],
);
const components: Components<Omit<Theme, 'components'>> | undefined = useMemo(
() =>
mode === 'light'
? {}
: {
MuiCssBaseline: {
styleOverrides: {
body: darkScrollbar(),
},
},
},
[mode],
);
return useMemo(
() =>
createTheme({
palette,
typography: {
fontFamily: "'Roboto', -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif",
h1: {
fontSize: '42px',
fontWeight: 'bold',
lineHeight: 1.3,
marginBottom: '16px',
[theme.breakpoints.down('md')]: {
fontSize: '30px',
},
},
h2: {
fontSize: '24px',
lineHeight: 1.3,
position: 'relative',
[theme.breakpoints.down('md')]: {
fontSize: '20px',
},
},
h3: {
fontSize: '20px',
lineHeight: 1.3,
position: 'relative',
[theme.breakpoints.down('md')]: {
fontSize: '18px',
},
},
},
components,
}),
[components, palette, theme.breakpoints],
);
};
export default useCreateTheme;

View File

@ -0,0 +1,23 @@
import { useMemo } from 'react';
import { isNotEmpty } from './string.util';
import type { ReactNode } from 'react';
export const getNodeText = (node: ReactNode): string => {
if (['string', 'number'].includes(typeof node)) {
return `${node}`;
}
if (node instanceof Array) {
return node.map(getNodeText).filter(isNotEmpty).join('');
}
if (typeof node === 'object' && node && 'props' in node) {
return getNodeText(node.props.children);
}
return '';
};
export const useNodeText = (node: ReactNode) => useMemo(() => getNodeText(node), [node]);

View File

@ -0,0 +1,11 @@
export function isNotNullish<T>(value: T | null | undefined): value is T {
return value !== undefined && value !== null;
}
export function isNullish<T>(value: T | null | undefined): value is null | undefined {
return value === undefined || value === null;
}
export function filterNullish<T>(value: (T | null | undefined)[] | null | undefined): T[] {
return value?.filter<T>(isNotNullish) ?? [];
}

View File

@ -0,0 +1,89 @@
import { useMemo } from 'react';
import { isEmpty } from './string.util';
import type { DocsPageHeading, SearchablePage } from '../interface';
const PARTIAL_MATCH_WORD_LENGTH_THRESHOLD = 5;
const WHOLE_WORD_MATCH_FAVOR_WEIGHT = 2;
const TITLE_FAVOR_WEIGHT = 15;
export interface SearchScore {
entry: SearchablePage;
metaScore: number;
score: number;
isExactTitleMatch: boolean;
matchedHeader: DocsPageHeading | undefined;
}
function getSearchScore(words: string[], entry: SearchablePage): SearchScore {
let score = 0;
let metaScore = 0;
for (const word of words) {
score +=
(entry.title.match(new RegExp(`\\b${word}\\b`, 'gi')) ?? []).length *
TITLE_FAVOR_WEIGHT *
WHOLE_WORD_MATCH_FAVOR_WEIGHT;
score +=
(entry.textContent.match(new RegExp(`\\b${word}\\b`, 'gi')) ?? []).length *
WHOLE_WORD_MATCH_FAVOR_WEIGHT;
if (word.length >= PARTIAL_MATCH_WORD_LENGTH_THRESHOLD) {
score += (entry.title.match(new RegExp(`${word}`, 'gi')) ?? []).length * TITLE_FAVOR_WEIGHT;
score += (entry.textContent.match(new RegExp(`${word}`, 'gi')) ?? []).length;
}
}
const exactMatchFavorWeight = words.length;
const exactSearch = words.join(' ').toLowerCase();
const isExactTitleMatch = entry.title.toLowerCase().includes(exactSearch);
const exactTitleMatchScore =
(isExactTitleMatch ? 1 : 0) *
TITLE_FAVOR_WEIGHT *
exactMatchFavorWeight *
WHOLE_WORD_MATCH_FAVOR_WEIGHT;
if (isExactTitleMatch) {
metaScore += 1;
}
score += exactTitleMatchScore;
score +=
(entry.textContent.match(new RegExp(`\\b${exactSearch}\\b`, 'gi')) ?? []).length *
exactMatchFavorWeight *
WHOLE_WORD_MATCH_FAVOR_WEIGHT;
return {
score,
metaScore,
entry,
isExactTitleMatch: exactTitleMatchScore > 0,
matchedHeader: entry.headings.find(header => header.title.toLowerCase().includes(exactSearch)),
};
}
export function useSearchScores(query: string | null, entries: SearchablePage[]): SearchScore[] {
return useMemo(() => {
if (!query || isEmpty(query.trim())) {
return [];
}
const queryWords = query.split(' ').filter(word => word.trim().length > 0);
const scores = entries
.map(entry => getSearchScore(queryWords, entry))
.filter(result => result.score > 0);
scores.sort((a, b) => {
if (a.metaScore !== b.metaScore) {
return b.metaScore - a.metaScore;
}
return b.score - a.score;
});
return scores;
}, [entries, query]);
}

View File

@ -0,0 +1,24 @@
import { isNotNullish, isNullish } from './null.util';
export function isEmpty(value: string | null | undefined): value is null | undefined {
return isNullish(value) || value === '';
}
export function isNotEmpty(value: string | null | undefined): value is string {
return isNotNullish(value) && value !== '';
}
export function toTitleCase(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}
export function toTitleCaseFromKey(str: string) {
return str.replace(/_/g, ' ').replace(/\w\S*/g, toTitleCase);
}
export function toTitleCaseFromVariableName(str: string) {
return str
.split(/(?=[A-Z])/)
.join(' ')
.replace(/\w\S*/g, toTitleCase);
}

View File

@ -0,0 +1,7 @@
import type { CreateMUIStyled } from '@mui/material/styles';
const transientOptions: Parameters<CreateMUIStyled>[1] = {
shouldForwardProp: (propName: string) => !propName.startsWith('$'),
};
export default transientOptions;