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;