refactor: monorepo setup with lerna (#243)
This commit is contained in:
committed by
GitHub
parent
dac29fbf3c
commit
504d95c34f
25
packages/docs/src/components/DateDisplay.tsx
Normal file
25
packages/docs/src/components/DateDisplay.tsx
Normal 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;
|
38
packages/docs/src/components/community/CommunitySection.tsx
Normal file
38
packages/docs/src/components/community/CommunitySection.tsx
Normal 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;
|
@ -0,0 +1,6 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const ColorModeContext = createContext({ toggleColorMode: () => {} });
|
||||
|
||||
export default ColorModeContext;
|
293
packages/docs/src/components/docs/DocsContent.tsx
Normal file
293
packages/docs/src/components/docs/DocsContent.tsx
Normal 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;
|
42
packages/docs/src/components/docs/DocsLeftNav.tsx
Normal file
42
packages/docs/src/components/docs/DocsLeftNav.tsx
Normal 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;
|
68
packages/docs/src/components/docs/DocsLeftNavGroup.tsx
Normal file
68
packages/docs/src/components/docs/DocsLeftNavGroup.tsx
Normal 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;
|
7
packages/docs/src/components/docs/DocsRightNav.tsx
Normal file
7
packages/docs/src/components/docs/DocsRightNav.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import DocsTableOfContents from './table_of_contents/DocsTableOfContents';
|
||||
|
||||
const DocsRightNav = () => {
|
||||
return <DocsTableOfContents />;
|
||||
};
|
||||
|
||||
export default DocsRightNav;
|
57
packages/docs/src/components/docs/components/Anchor.tsx
Normal file
57
packages/docs/src/components/docs/components/Anchor.tsx
Normal 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;
|
71
packages/docs/src/components/docs/components/Blockquote.tsx
Normal file
71
packages/docs/src/components/docs/components/Blockquote.tsx
Normal 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;
|
142
packages/docs/src/components/docs/components/CodeTabs.tsx
Normal file
142
packages/docs/src/components/docs/components/CodeTabs.tsx
Normal 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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
61
packages/docs/src/components/docs/components/table/Table.tsx
Normal file
61
packages/docs/src/components/docs/components/table/Table.tsx
Normal 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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
28
packages/docs/src/components/layout/Container.tsx
Normal file
28
packages/docs/src/components/layout/Container.tsx
Normal 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;
|
69
packages/docs/src/components/layout/Footer.tsx
Normal file
69
packages/docs/src/components/layout/Footer.tsx
Normal 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;
|
209
packages/docs/src/components/layout/Header.tsx
Normal file
209
packages/docs/src/components/layout/Header.tsx
Normal 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;
|
22
packages/docs/src/components/layout/Logo.tsx
Normal file
22
packages/docs/src/components/layout/Logo.tsx
Normal 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;
|
107
packages/docs/src/components/layout/Page.tsx
Normal file
107
packages/docs/src/components/layout/Page.tsx
Normal 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;
|
@ -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;
|
@ -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;
|
@ -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;
|
85
packages/docs/src/components/layout/search/Search.tsx
Normal file
85
packages/docs/src/components/layout/search/Search.tsx
Normal 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;
|
257
packages/docs/src/components/layout/search/SearchModal.tsx
Normal file
257
packages/docs/src/components/layout/search/SearchModal.tsx
Normal file
@ -0,0 +1,257 @@
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import { styled, useTheme } from '@mui/material/styles';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useSearchScores } from '../../../util/search.util';
|
||||
import { isNotEmpty } from '../../../util/string.util';
|
||||
import SearchResult from './SearchResult';
|
||||
import SuggestionLink from './SuggestionLink';
|
||||
|
||||
import type { ChangeEvent, FC } from 'react';
|
||||
import type { SearchablePage } from '../../../interface';
|
||||
|
||||
const SEARCH_RESULTS_TO_SHOW = 5;
|
||||
|
||||
const StyledDialog = styled(Dialog)(
|
||||
({ theme }) => `
|
||||
${theme.breakpoints.between('md', 'lg')} {
|
||||
& .MuiDialog-paper {
|
||||
width: 60vw;
|
||||
height: 60vh;
|
||||
maxHeight: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
${theme.breakpoints.up('lg')} {
|
||||
& .MuiDialog-paper {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const StyledDialogContent = styled(DialogContent)`
|
||||
padding: 0;
|
||||
`;
|
||||
|
||||
const StyledTextField = styled(TextField)`
|
||||
height: 68px;
|
||||
|
||||
& .MuiInputBase-root {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
& .MuiInputBase-root.MuiInput-root::after {
|
||||
border-bottom: unset;
|
||||
}
|
||||
|
||||
& .MuiInput-root {
|
||||
font-size: 18px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSuggestions = styled('div')`
|
||||
display: grid;
|
||||
gap: 48px;
|
||||
padding: 24px;
|
||||
grid-template-columns: repeat(2, minmax(0, 2fr));
|
||||
grid-template-rows: repeat(2, minmax(0, 2fr));
|
||||
`;
|
||||
|
||||
const StyledSuggestionSection = styled('div')`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
searchablePages: SearchablePage[];
|
||||
}
|
||||
|
||||
const SearchModal: FC<SearchModalProps> = ({ open, onClose, searchablePages }) => {
|
||||
const inputRef = useRef<HTMLInputElement>();
|
||||
|
||||
const theme = useTheme();
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
const [canFocus, setCanFocus] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const handleFocus = () => {
|
||||
if (canFocus && open && inputRef.current) {
|
||||
inputRef.current?.focus();
|
||||
setSearch('');
|
||||
setCanFocus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setCanFocus(true);
|
||||
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;
|
117
packages/docs/src/components/layout/search/SearchResult.tsx
Normal file
117
packages/docs/src/components/layout/search/SearchResult.tsx
Normal 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;
|
@ -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;
|
26
packages/docs/src/components/meta/BasicMeta.tsx
Normal file
26
packages/docs/src/components/meta/BasicMeta.tsx
Normal 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;
|
37
packages/docs/src/components/meta/JsonLdMeta.tsx
Normal file
37
packages/docs/src/components/meta/JsonLdMeta.tsx
Normal 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;
|
35
packages/docs/src/components/meta/OpenGraphMeta.tsx
Normal file
35
packages/docs/src/components/meta/OpenGraphMeta.tsx
Normal 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;
|
37
packages/docs/src/components/meta/TwitterCardMeta.tsx
Normal file
37
packages/docs/src/components/meta/TwitterCardMeta.tsx
Normal 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;
|
Reference in New Issue
Block a user