mirror of https://github.com/coder/coder.git
546 lines
14 KiB
TypeScript
546 lines
14 KiB
TypeScript
import {
|
|
Box,
|
|
Button,
|
|
Code,
|
|
Drawer,
|
|
DrawerBody,
|
|
DrawerCloseButton,
|
|
DrawerContent,
|
|
DrawerOverlay,
|
|
Flex,
|
|
Grid,
|
|
GridProps,
|
|
Heading,
|
|
Icon,
|
|
Img,
|
|
Link,
|
|
OrderedList,
|
|
Table,
|
|
TableContainer,
|
|
Td,
|
|
Text,
|
|
Th,
|
|
Thead,
|
|
Tr,
|
|
UnorderedList,
|
|
useDisclosure,
|
|
} from "@chakra-ui/react";
|
|
import fm from "front-matter";
|
|
import { readFileSync } from "fs";
|
|
import _ from "lodash";
|
|
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
|
|
import Head from "next/head";
|
|
import NextLink from "next/link";
|
|
import { useRouter } from "next/router";
|
|
import path from "path";
|
|
import { MdMenu } from "react-icons/md";
|
|
import ReactMarkdown from "react-markdown";
|
|
import rehypeRaw from "rehype-raw";
|
|
import remarkGfm from "remark-gfm";
|
|
|
|
type FilePath = string;
|
|
type UrlPath = string;
|
|
type Route = {
|
|
path: FilePath;
|
|
title: string;
|
|
description?: string;
|
|
children?: Route[];
|
|
};
|
|
type Manifest = { versions: string[]; routes: Route[] };
|
|
type NavItem = { title: string; path: UrlPath; children?: NavItem[] };
|
|
type Nav = NavItem[];
|
|
|
|
const readContentFile = (filePath: string) => {
|
|
const baseDir = process.cwd();
|
|
const docsPath = path.join(baseDir, "..", "docs");
|
|
return readFileSync(path.join(docsPath, filePath), { encoding: "utf-8" });
|
|
};
|
|
|
|
const removeTrailingSlash = (path: string) => path.replace(/\/+$/, "");
|
|
|
|
const removeMkdExtension = (path: string) => path.replace(/\.md/g, "");
|
|
|
|
const removeIndexFilename = (path: string) => {
|
|
if (path.endsWith("index")) {
|
|
path = path.replace("index", "");
|
|
}
|
|
|
|
return path;
|
|
};
|
|
|
|
const removeREADMEName = (path: string) => {
|
|
if (path.startsWith("README")) {
|
|
path = path.replace("README", "");
|
|
}
|
|
|
|
return path;
|
|
};
|
|
|
|
// transformLinkUri converts the links in the markdown file to
|
|
// href html links. All index page routes are the directory name, and all
|
|
// other routes are the filename without the .md extension.
|
|
// This means all relative links are off by one directory on non-index pages.
|
|
//
|
|
// index.md -> ./subdir/file = ./subdir/file
|
|
// index.md -> ../file-next-to-index = ./file-next-to-index
|
|
// file.md -> ./subdir/file = ../subdir/file
|
|
// file.md -> ../file-next-to-file = ../file-next-to-file
|
|
const transformLinkUriSource = (sourceFile: string) => {
|
|
return (href = "") => {
|
|
const isExternal = href.startsWith("http") || href.startsWith("https");
|
|
if (!isExternal) {
|
|
// Remove .md form the path
|
|
href = removeMkdExtension(href);
|
|
|
|
// Add the extra '..' if not an index file.
|
|
sourceFile = removeMkdExtension(sourceFile);
|
|
if (!sourceFile.endsWith("index")) {
|
|
href = "../" + href;
|
|
}
|
|
|
|
// Remove the index path
|
|
href = removeIndexFilename(href);
|
|
href = removeREADMEName(href);
|
|
}
|
|
return href;
|
|
};
|
|
};
|
|
|
|
const transformFilePathToUrlPath = (filePath: string) => {
|
|
// Remove markdown extension
|
|
let urlPath = removeMkdExtension(filePath);
|
|
|
|
// Remove relative path
|
|
if (urlPath.startsWith("./")) {
|
|
urlPath = urlPath.replace("./", "");
|
|
}
|
|
|
|
// Remove index from the root file
|
|
urlPath = removeIndexFilename(urlPath);
|
|
urlPath = removeREADMEName(urlPath);
|
|
|
|
// Remove trailing slash
|
|
if (urlPath.endsWith("/")) {
|
|
urlPath = removeTrailingSlash(urlPath);
|
|
}
|
|
|
|
return urlPath;
|
|
};
|
|
|
|
const mapRoutes = (manifest: Manifest): Record<UrlPath, Route> => {
|
|
const paths: Record<UrlPath, Route> = {};
|
|
|
|
const addPaths = (routes: Route[]) => {
|
|
for (const route of routes) {
|
|
paths[transformFilePathToUrlPath(route.path)] = route;
|
|
|
|
if (route.children) {
|
|
addPaths(route.children);
|
|
}
|
|
}
|
|
};
|
|
|
|
addPaths(manifest.routes);
|
|
|
|
return paths;
|
|
};
|
|
|
|
let manifest: Manifest | undefined;
|
|
|
|
const getManifest = () => {
|
|
if (manifest) {
|
|
return manifest;
|
|
}
|
|
|
|
const manifestContent = readContentFile("manifest.json");
|
|
manifest = JSON.parse(manifestContent) as Manifest;
|
|
return manifest;
|
|
};
|
|
|
|
let navigation: Nav | undefined;
|
|
|
|
const getNavigation = (manifest: Manifest): Nav => {
|
|
if (navigation) {
|
|
return navigation;
|
|
}
|
|
|
|
const getNavItem = (route: Route, parentPath?: UrlPath): NavItem => {
|
|
const path = parentPath
|
|
? `${parentPath}/${transformFilePathToUrlPath(route.path)}`
|
|
: transformFilePathToUrlPath(route.path);
|
|
const navItem: NavItem = {
|
|
title: route.title,
|
|
path,
|
|
};
|
|
|
|
if (route.children) {
|
|
navItem.children = [];
|
|
|
|
for (const childRoute of route.children) {
|
|
navItem.children.push(getNavItem(childRoute));
|
|
}
|
|
}
|
|
|
|
return navItem;
|
|
};
|
|
|
|
navigation = [];
|
|
|
|
for (const route of manifest.routes) {
|
|
navigation.push(getNavItem(route));
|
|
}
|
|
|
|
return navigation;
|
|
};
|
|
|
|
const removeHtmlComments = (string: string) => {
|
|
return string.replace(/<!--[\s\S]*?-->/g, "");
|
|
};
|
|
|
|
export const getStaticPaths: GetStaticPaths = () => {
|
|
const manifest = getManifest();
|
|
const routes = mapRoutes(manifest);
|
|
const paths = Object.keys(routes).map((urlPath) => ({
|
|
params: { slug: urlPath.split("/") },
|
|
}));
|
|
|
|
return {
|
|
paths,
|
|
fallback: false,
|
|
};
|
|
};
|
|
|
|
export const getStaticProps: GetStaticProps = (context) => {
|
|
// When it is home page, the slug is undefined because there is no url path
|
|
// so we make it an empty string to work good with the mapRoutes
|
|
const { slug = [""] } = context.params as { slug: string[] };
|
|
const manifest = getManifest();
|
|
const routes = mapRoutes(manifest);
|
|
const urlPath = slug.join("/");
|
|
const route = routes[urlPath];
|
|
const { body } = fm(readContentFile(route.path));
|
|
// Serialize MDX to support custom components
|
|
const content = removeHtmlComments(body);
|
|
const navigation = getNavigation(manifest);
|
|
const version = manifest.versions[0];
|
|
|
|
return {
|
|
props: {
|
|
content,
|
|
navigation,
|
|
route,
|
|
version,
|
|
},
|
|
};
|
|
};
|
|
|
|
const SidebarNavItem: React.FC<{ item: NavItem; nav: Nav }> = ({
|
|
item,
|
|
nav,
|
|
}) => {
|
|
const router = useRouter();
|
|
let isActive = router.asPath.startsWith(`/${item.path}`);
|
|
|
|
// Special case to handle the home path
|
|
if (item.path === "") {
|
|
isActive = router.asPath === "/";
|
|
|
|
// Special case to handle the home path children
|
|
const homeNav = nav.find((navItem) => navItem.path === "") as NavItem;
|
|
const homeNavPaths =
|
|
homeNav.children?.map((item) => `/${item.path}/`) ?? [];
|
|
if (homeNavPaths.includes(router.asPath)) {
|
|
isActive = true;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Box>
|
|
<NextLink href={"/" + item.path} passHref>
|
|
<Link
|
|
fontWeight={isActive ? 600 : 400}
|
|
color={isActive ? "gray.900" : "gray.700"}
|
|
>
|
|
{item.title}
|
|
</Link>
|
|
</NextLink>
|
|
|
|
{isActive && item.children && (
|
|
<Grid
|
|
as="nav"
|
|
pt={2}
|
|
pl={3}
|
|
maxW="sm"
|
|
autoFlow="row"
|
|
gap={2}
|
|
autoRows="min-content"
|
|
>
|
|
{item.children.map((subItem) => (
|
|
<SidebarNavItem key={subItem.path} item={subItem} nav={nav} />
|
|
))}
|
|
</Grid>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
const SidebarNav: React.FC<{ nav: Nav; version: string } & GridProps> = ({
|
|
nav,
|
|
version,
|
|
...gridProps
|
|
}) => {
|
|
return (
|
|
<Grid
|
|
h="100vh"
|
|
overflowY="scroll"
|
|
as="nav"
|
|
p={8}
|
|
w="300px"
|
|
autoFlow="row"
|
|
gap={2}
|
|
autoRows="min-content"
|
|
bgColor="white"
|
|
borderRightWidth={1}
|
|
borderColor="gray.200"
|
|
borderStyle="solid"
|
|
{...gridProps}
|
|
>
|
|
<Box mb={6}>
|
|
<Img src="/logo.svg" alt="Coder logo" />
|
|
</Box>
|
|
|
|
{nav.map((navItem) => (
|
|
<SidebarNavItem key={navItem.path} item={navItem} nav={nav} />
|
|
))}
|
|
</Grid>
|
|
);
|
|
};
|
|
|
|
const MobileNavbar: React.FC<{ nav: Nav; version: string }> = ({
|
|
nav,
|
|
version,
|
|
}) => {
|
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
|
|
|
return (
|
|
<>
|
|
<Flex
|
|
bgColor="white"
|
|
px={6}
|
|
alignItems="center"
|
|
h={16}
|
|
borderBottomWidth={1}
|
|
>
|
|
<Img src="/logo.svg" alt="Coder logo" w={28} />
|
|
|
|
<Button variant="ghost" ml="auto" onClick={onOpen}>
|
|
<Icon as={MdMenu} fontSize="2xl" />
|
|
</Button>
|
|
</Flex>
|
|
|
|
<Drawer onClose={onClose} isOpen={isOpen}>
|
|
<DrawerOverlay />
|
|
<DrawerContent>
|
|
<DrawerCloseButton />
|
|
<DrawerBody p={0}>
|
|
<SidebarNav nav={nav} version={version} border={0} />
|
|
</DrawerBody>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const slugifyTitle = (title: string) => {
|
|
return _.kebabCase(title.toLowerCase());
|
|
};
|
|
|
|
const getImageUrl = (src: string | undefined) => {
|
|
if (src === undefined) {
|
|
return "";
|
|
}
|
|
const assetPath = src.split("images/")[1];
|
|
return `/images/${assetPath}`;
|
|
};
|
|
|
|
const DocsPage: NextPage<{
|
|
content: string;
|
|
navigation: Nav;
|
|
route: Route;
|
|
version: string;
|
|
}> = ({ content, navigation, route, version }) => {
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{route.title}</title>
|
|
<meta name="source" content={route.path} />
|
|
</Head>
|
|
<Box
|
|
display={{ md: "grid" }}
|
|
gridTemplateColumns="max-content 1fr"
|
|
fontSize="md"
|
|
color="gray.700"
|
|
>
|
|
<Box display={{ base: "none", md: "block" }}>
|
|
<SidebarNav nav={navigation} version={version} />
|
|
</Box>
|
|
|
|
<Box display={{ base: "block", md: "none" }}>
|
|
<MobileNavbar nav={navigation} version={version} />
|
|
</Box>
|
|
|
|
<Box
|
|
as="main"
|
|
w="full"
|
|
pb={20}
|
|
px={{ base: 6, md: 10 }}
|
|
pl={{ base: 6, md: 20 }}
|
|
h="100vh"
|
|
overflowY="auto"
|
|
>
|
|
<Box maxW="872">
|
|
<Box lineHeight="tall">
|
|
{/* Some docs don't have the title */}
|
|
<Heading
|
|
as="h1"
|
|
fontSize="4xl"
|
|
pt={10}
|
|
pb={2}
|
|
// Hide this title if the doc has the title already
|
|
sx={{ "& + h1": { display: "none" } }}
|
|
>
|
|
{route.title}
|
|
</Heading>
|
|
<ReactMarkdown
|
|
rehypePlugins={[rehypeRaw]}
|
|
remarkPlugins={[remarkGfm]}
|
|
transformLinkUri={transformLinkUriSource(route.path)}
|
|
components={{
|
|
h1: ({ children }) => (
|
|
<Heading
|
|
as="h1"
|
|
fontSize="4xl"
|
|
pt={10}
|
|
pb={2}
|
|
id={slugifyTitle(children[0] as string)}
|
|
>
|
|
{children}
|
|
</Heading>
|
|
),
|
|
h2: ({ children }) => (
|
|
<Heading
|
|
as="h2"
|
|
fontSize="3xl"
|
|
pt={10}
|
|
pb={2}
|
|
id={slugifyTitle(children[0] as string)}
|
|
>
|
|
{children}
|
|
</Heading>
|
|
),
|
|
h3: ({ children }) => (
|
|
<Heading
|
|
as="h3"
|
|
fontSize="2xl"
|
|
pt={10}
|
|
pb={2}
|
|
id={slugifyTitle(children[0] as string)}
|
|
>
|
|
{children}
|
|
</Heading>
|
|
),
|
|
img: ({ src }) => (
|
|
<Img
|
|
src={getImageUrl(src)}
|
|
mb={2}
|
|
borderWidth={1}
|
|
borderColor="gray.200"
|
|
borderStyle="solid"
|
|
rounded="md"
|
|
height="auto"
|
|
/>
|
|
),
|
|
p: ({ children }) => (
|
|
<Text pt={2} pb={2}>
|
|
{children}
|
|
</Text>
|
|
),
|
|
ul: ({ children }) => (
|
|
<UnorderedList
|
|
mb={4}
|
|
display="grid"
|
|
gridAutoFlow="row"
|
|
gap={2}
|
|
>
|
|
{children}
|
|
</UnorderedList>
|
|
),
|
|
ol: ({ children }) => (
|
|
<OrderedList
|
|
mb={4}
|
|
display="grid"
|
|
gridAutoFlow="row"
|
|
gap={2}
|
|
>
|
|
{children}
|
|
</OrderedList>
|
|
),
|
|
a: ({ children, href = "" }) => {
|
|
const isExternal =
|
|
href.startsWith("http") || href.startsWith("https");
|
|
|
|
return (
|
|
<Link
|
|
href={href}
|
|
target={isExternal ? "_blank" : undefined}
|
|
fontWeight={500}
|
|
color="blue.600"
|
|
>
|
|
{children}
|
|
</Link>
|
|
);
|
|
},
|
|
code: ({ node, ...props }) => (
|
|
<Code {...props} bgColor="gray.100" />
|
|
),
|
|
pre: ({ children }) => (
|
|
<Box
|
|
as="pre"
|
|
w="full"
|
|
sx={{ "& > code": { w: "full", p: 4, rounded: "md" } }}
|
|
mb={2}
|
|
>
|
|
{children}
|
|
</Box>
|
|
),
|
|
table: ({ children }) => (
|
|
<TableContainer
|
|
mt={1}
|
|
mb={2}
|
|
bgColor="white"
|
|
rounded="md"
|
|
borderWidth={1}
|
|
borderColor="gray.100"
|
|
borderStyle="solid"
|
|
>
|
|
<Table variant="simple">{children}</Table>
|
|
</TableContainer>
|
|
),
|
|
thead: ({ children }) => <Thead>{children}</Thead>,
|
|
th: ({ children }) => <Th>{children}</Th>,
|
|
td: ({ children }) => <Td>{children}</Td>,
|
|
tr: ({ children }) => <Tr>{children}</Tr>,
|
|
}}
|
|
>
|
|
{content}
|
|
</ReactMarkdown>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default DocsPage;
|