mirror of
https://github.com/coder/coder.git
synced 2025-07-23 21:32:07 +00:00
feat: add offline docs (#8527)
This commit is contained in:
544
offlinedocs/pages/[[...slug]].tsx
Normal file
544
offlinedocs/pages/[[...slug]].tsx
Normal file
@ -0,0 +1,544 @@
|
||||
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
|
Reference in New Issue
Block a user