This submit was initially printed on my blog
Have you ever ever wished to construct a docs website in your open-source library or aspect mission?
Docusaurus is a well-liked framework for producing docs from Markdown/MDX information and it does a fantastic job! Nonetheless, maybe you could have already an present Subsequent.js web site so don’t need to create one other one from a complete new codebase or on one other area.
On this submit, we’ll construct a static docs generator powered by Subsequent.js and Contentlayer. Contentlayer is one thing like Prisma for native Markdown content material — you outline the schema of paperwork to make Contentlayer generate validated (so type-safe) JSON knowledge you possibly can import from wherever.
What we’ll construct on this submit
Create a Subsequent.js app and set up dependencies
Now, let’s get began by creating a brand new Subsequent app. Be sure that to opt-in to TypeScript.
pnpm create next-app
# And reply the prompts
pnpm add contentlayer next-contentlayer sass clsx @heroicons/react remark-gfm rehype-prism-plus
Join Contentlayer with Subsequent.js
Comply with the Getting Began to combine Contentlayer into Subsequent.js.
// subsequent.config.mjs
import { withContentlayer } from "next-contentlayer";
/** @sort {import('subsequent').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
};
module.exports = withContentlayer(nextConfig);
A cool factor is that Contentlayer triggers Quick Refresh as you edit Markdown content material, when you want handbook reloads with a naive fs.readFile
method.
Outline Contentlayer doc
Then, we’ll outline a Docs
schema. You might really feel accustomed to this (and code era) when you have ever used Prisma.
We included remark-gfm
to assist GitHub Flavored Markdown and rehype-prism-plus
for syntax spotlight of code blocks.
// contentlayer.config.ts
import { defineDocumentType, makeSource } from "contentlayer/source-files";
import rehypePrismPlus from "rehype-prism-plus";
import remarkGfm from "remark-gfm";
export const Docs = defineDocumentType(() => ({
title: "Docs",
filePathPattern: `docs/**/*.mdx`,
contentType: "mdx",
fields: {
id: {
sort: "string",
},
title: {
sort: "string",
required: true,
},
},
computedFields: {
id: doc._raw.flattenedPath.substitute("docs/", ""),
,
slug: {
sort: "string",
resolve: (doc) => doc._raw.flattenedPath.substitute("docs/", ""),
},
},
}));
export default makeSource({
contentDirPath: "content material",
documentTypes: [Docs],
mdx: {
remarkPlugins: [remarkGfm],
rehypePlugins: [[rehypePrismPlus, { ignoreMissing: true }]],
},
});
Add MDX content material
Let’s write MDX content material and add it beneath content material/docs
listing. Be sure that to place a picture named automotive.jpg in /public
listing.
/content material/docs/pattern.mdx
---
id: "pattern"
title: "That is the title"
---
## Heading degree 2
### Heading degree 3
Ullamco et `nostrud magna` commodo nostrud occaecat quis pariatur id ipsum. Ipsum
consequat enim id excepteur consequat nostrud esse esse fugiat dolore.
Reprehenderit occaecat exercitation non cupidatat in eiusmod laborum ex eu
fugiat aute culpa pariatur. Irure elit proident consequat veniam minim ipsum ex
pariatur.
- record merchandise 1
- record merchandise 2
- record merchandise 3
### Heading degree 3
![image alt](/automotive.jpg)
``ts
perform sum(a: quantity, b: quantity) {
return a + b
}
``
Line on the high is the file title. Don’t embody it!
Create a dynamic route for docs pages
Now, you possibly can simply import all of the docs from contentlayer/generated
with validated and typed metadata. No have to dig into the file construction or deal with invalid content material. Clear!
// /pages/docs/[...slug].tsx
import React from "react";
import { GetStaticPathsResult, GetStaticPropsContext } from "subsequent";
import { useMDXComponent } from "next-contentlayer/hooks";
import { allDocs, sort Docs } from "contentlayer/generated";
sort Props = {
doc: Docs;
};
export default perform DocsPage({ doc }: Props) {
const MDXContent = useMDXComponent(doc.physique.code);
return (
<div>
<h1>{doc.title}</h1>
<MDXContent />
</div>
);
}
export async perform getStaticProps({ params }: GetStaticPropsContext) {
const slug = params?.slug;
if (!Array.isArray(slug)) {
return {
notFound: true,
};
}
const doc = allDocs.discover((submit) => submit.slug === slug.be part of("/"));
if (!doc) {
return { notFound: true };
}
const props: Props = {
doc,
};
return {
props,
};
}
export async perform getStaticPaths(): Promise<GetStaticPathsResult> {
const paths = allDocs.map((doc) => ({
params: { slug: doc.slug.break up("/") },
}));
return {
paths,
fallback: false,
};
}
Add CSS information
Earlier than writing types, we’d like so as to add world CSS information.
// /pages/_app.tsx
import "../types/normalize.css"; // Reset browser defaults
import "../types/tokens.css"; // Contains CSS variables
import "../types/prism.css"; // Syntax spotlight
If Prism is working appropriately, code blocks (surrounded by triple backticks) look fairly just like the under:
Type MDX content material
We’d wish to type the article by wrapping <MDXContent />
with <Markup />
part. This isn’t the one method however the easiest resolution.
/// parts/Markup/index.tsx
import React from "react";
import types from "./types.module.scss";
sort Props = {
kids?: React.ReactNode;
};
export default perform Markup({ kids }: Props) {
return <div className={types.container}>{kids}</div>;
}
// /parts/Markup/types.module.scss
.container {
:the place(h2, h3, h4) {
margin-top: 2.25rem;
margin-bottom: 1.2rem;
line-height: 1.3;
}
:the place(desk, img, blockquote) {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
:the place(p) {
margin-top: 1rem;
}
:the place(ul, ol):not(:first-child) {
margin: 1.5rem 0;
}
:the place(ul, ol) :the place(ul, ol) {
// margin-top must be the identical because the hole of record gadgets.
margin: 0.5rem 0 0 0;
}
> *:first-child {
margin-top: 0;
}
:the place(h2) {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xl);
border-bottom: 1px strong var(--color-gray-200);
padding-bottom: 0.3rem;
}
:the place(h3) {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-xl);
}
:the place(h4) {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-lg);
}
:the place(a) {
colour: var(--color-primary-600);
font-weight: var(--font-weight-medium);
&:hover {
text-decoration: underline;
}
}
:the place(code):not([class*="language-"]) {
background-color: var(--color-gray-100);
padding: 0.1em 0.3em;
border: 1px strong var(--color-gray-200);
border-radius: var(--rounded-md);
font-family: var(--font-family-code);
font-weight: var(--font-weight-medium);
font-size: 0.9em;
overflow: auto;
}
:the place(pre):not([class*="language-"]) {
// Firefox would not assist :has() but, however OK as not so vital.
&:has(code) {
margin-top: 2rem;
margin-bottom: 2rem;
}
}
:the place(blockquote) {
border-left: 0.2rem strong var(--color-gray-300);
padding-left: 1em;
}
:the place(ul, ol) {
padding-left: 1.5rem;
show: flex;
flex-direction: column;
hole: 0.5rem;
}
:the place(ul) {
list-style: disc;
}
:the place(ul[class~="contains-task-list"]) {
list-style: none;
}
:the place(ol) {
list-style: decimal;
}
:the place(p) {
line-height: 1.75;
}
}
// /pages/docs/[...slug].tsx
export default perform DocsPage({ doc }: Props) {
return (
{...}
<Markup>
<MDXContent />
</Markup>
{...}
);
}
Structure for the sidebar navigation
Then, let’s create a format for the sidebar navigation.
// /parts/DocsTemplate/index.tsx
import { useMDXComponent } from "next-contentlayer/hooks";
import { sort Docs } from "contentlayer/generated";
import Markup from "../Markup";
import types from "./types.module.scss";
sort Props = {
doc: Docs;
};
export default perform DocsTemplate({ doc: { title, physique } }: Props) {
const MDXContent = useMDXComponent(physique.code);
return (
<div className={types.container}>
<div />
<article className={types.article}>
<header className={types.header}>
<h1 className={types.title}>{title}</h1>
</header>
<Markup>
<MDXContent />
</Markup>
</article>
</div>
);
}
// /parts/DocsTemplate/types.module.scss
.container {
show: grid;
grid-template-columns: 18rem 1fr;
column-gap: 1rem;
}
.article {
padding: 1rem;
}
.header {
padding-bottom: 2rem;
.title {
font-weight: var(--font-weight-extrabold);
font-size: var(--font-size-5xl);
line-height: 1.2;
margin: 0;
}
}
// /pages/docs/[...slug].tsx
export default perform DocsPage({ doc }: Props) {
return <DocsTemplate doc={doc} />;
}
Create Sidebar part
We need to make navigation settle for arbitrary ranges of nested classes like /class/article.mdx
and /cateogry/another-category/article.mdx
.
To assist this, we outline a recursive sort (not an official title) as follows:
// /sorts.ts
export sort NavItemCategory = {
id: string;
label: string;
open?: boolean;
gadgets: NavItem[]; // Self reference
};
export sort NavItemLink = {
id: string;
label: string;
href: string;
};
export sort NavItem = NavItemCategory | NavItemLink;
And Sidebar
part handles the handed knowledge in a recursive method:
// /parts/Sidebar/index.tsx
import { ChevronRightIcon } from "@heroicons/react/20/strong";
import clsx from "clsx";
import Hyperlink from "subsequent/hyperlink";
import { useRouter } from "subsequent/router";
import React from "react";
import { NavItem } from "../../sorts";
import types from "./types.module.scss";
sort Props = {
gadgets: NavItem[];
};
export default perform Sidebar({ gadgets }: Props) {
return (
<nav className={types.container}>
<ul className={types.record}>
{gadgets.map((merchandise) => (
<Merchandise key={merchandise.id} merchandise={merchandise} />
))}
</ul>
</nav>
);
}
sort ItemProps = {
merchandise: NavItem;
};
perform Merchandise({ merchandise }: ItemProps) {
const router = useRouter();
const isActive = React.useCallback(
(href: string) => href === router.asPath,
[router.asPath]
);
if ("gadgets" in merchandise) {
// Class
return (
<li className={types.class}>
<particulars>
<abstract className={types.button}>
{merchandise.label}
<ChevronRightIcon />
</abstract>
<ul className={types.record}>
{merchandise.gadgets.map((merchandise) => (
<Merchandise key={merchandise.id} merchandise={merchandise} /> {/* Recursion */}
))}
</ul>
</particulars>
</li>
);
} else {
// Doc hyperlink
return (
<li key={merchandise.href}>
<Hyperlink
href={merchandise.href}
className={clsx(
types.button,
isActive(merchandise.href) && types.isActive
)}
aria-current={isActive(merchandise.href) ? "web page" : undefined}
>
{merchandise.label}
</Hyperlink>
</li>
);
}
}
// /parts/Sidebar/types.module.scss
$rowGap: 0.25rem;
.container {
padding: 1rem;
border-right: 1px strong var(--color-gray-200);
top: 100vh;
place: sticky;
high: 0;
}
.record {
show: grid;
row-gap: $rowGap;
colour: var(--color-gray-600);
}
.class {
particulars[open] {
> abstract svg {
rotate: 90deg;
}
}
abstract {
show: flex;
align-items: heart;
justify-content: space-between;
user-select: none;
svg {
width: 1.6rem;
top: 1.6rem;
colour: var(--color-gray-500);
translate: 0.25rem;
}
}
abstract::-webkit-details-marker {
show: none;
}
ul {
margin-left: 1rem;
margin-top: $rowGap;
}
}
.button {
show: flex;
padding: 0.3rem 0.75rem;
border-radius: var(--rounded-md);
font-weight: var(--font-weight-medium);
transition: var(--transition-bg);
border: 1px strong clear;
cursor: pointer;
width: 100%;
&.isActive {
font-weight: var(--font-weight-semibold);
colour: var(--color-primary-500);
background-color: var(--color-gray-50);
border-color: var(--color-gray-200);
}
&:hover {
background-color: var(--color-gray-100);
}
}
Check Sidebar with dummy knowledge
// /parts/DocsTemplate/index.tsx
export default perform DocsTemplate({ doc: { title, physique } }: Props) {
const MDXContent = useMDXComponent(physique.code);
return (
<div className={types.container}>
<Sidebar
gadgets={[
{
id: "item1",
href: "#",
label: "Item 1",
},
{
id: "category1",
label: "Category 1",
items: [
{
id: "category1-1",
label: "Category 1-1",
href: "#",
},
{
id: "category1-2",
label: "Category 1-2",
href: "#",
},
],
},
]}
/>
<article className={types.article}>
{...}
</article>
</div>
);
}
Looks as if it’s working! The class is dealt with appropriately.
Sidebar knowledge from actual content material
Add categorized docs
/content material/docs/frameworks/react.mdx
---
id: "react"
title: "React"
---
Article about React.
/content material/docs/frameworks/vue.mdx
---
id: "vue"
title: "Vue"
---
Article about Vue.
Outline sidebar config
We would wish to specify the order and classes in sidebar.js
. For the config file, we outline sorts to take away href
and make label
non-compulsory (and default to the title) as follows:
// /sorts.ts
sort Non-obligatory<T, Okay extends keyof T> = Choose<Partial<T>, Okay> & Omit<T, Okay>;
export sort DocsSidebarItemConfig =
| NavItemCategory
| Non-obligatory<Omit<NavItemLink, "href">, "label">;
export sort DocsSidebarConfig = {
gadgets: DocsSidebarItemConfig[];
};
// /sidebar.js
/** @sort {import('./sorts').DocsSidebarConfig} */
const sidebar = {
gadgets: [
{
id: "sample",
},
{
id: "frameworks",
label: "Frameworks",
items: [
{
id: "react",
},
{
id: "vue",
},
],
},
],
};
export default sidebar;
// /lib/docs.ts
import { allDocs } from "contentlayer/generated";
import sidebar from "../sidebar";
import { NavItem, DocsSidebarItemConfig, NavItemLink } from "../sorts";
export perform getSidebarItems(
gadgets: DocsSidebarItemConfig[] = sidebar.gadgets
) {
const consequence: NavItem[] = [];
for (const merchandise of gadgets) {
if ("gadgets" in merchandise) {
// Class
consequence.push({
...merchandise,
gadgets: getSidebarItems(merchandise.gadgets),
});
} else {
// Doc hyperlink
const doc = allDocs.discover((d) => d.id === merchandise.id);
if (!doc) proceed;
consequence.push({
...merchandise,
href: "/docs/" + doc.slug,
label: merchandise.label ?? doc.title,
});
}
}
return consequence;
}
// /parts/DocsTemplate/index.tsx
sort Props = {
doc: Docs;
sidebarItems: NavItem[];
};
export default perform DocsTemplate({
doc: { title, physique },
sidebarItems,
}: Props) {
const MDXContent = useMDXComponent(physique.code);
return (
<div className={types.container}>
<Sidebar gadgets={sidebarItems} />
<article className={types.article}>
{...}
</article>
</div>
);
}
Name getSidebarItems()
to get navigation gadgets for the sidebar.
// /pages/docs/[...slug].tsx
sort Props = {
doc: Docs;
sidebarItems: NavItem[];
};
export default perform DocsPage({ doc, sidebarItems }: Props) {
return <DocsTemplate doc={doc} sidebarItems={sidebarItems} />;
}
export async perform getStaticProps({ params }: GetStaticPropsContext) {
// {...}
const doc = allDocs.discover((submit) => submit.slug === slug.be part of("/"));
const sidebarItems = getSidebarItems();
// {...}
const props: Props = {
doc,
sidebarItems,
};
// {...}
}
It really works similar to anticipated!
Nonetheless rather more to do…
We might construct a easy docs generator however there are such a lot of lacking issues:
- Picture optimization by subsequent/picture
- Desk of contents
- Subsequent/earlier navigation
- File title for code blocks
- Anchor hyperlink for every heading
- Utilizing React parts inside .mdx information
- On cellular units, the sidebar must be hidden and expanded as a modal menu
Saazy Template helps all of the lacking options!
I constructed Saazy Template, a Subsequent.js starter for advertising and marketing that features:
- Touchdown web page
- Pricing web page
- Check in web page
- And different 10+ pages
- Docs/weblog
- Built-in kinds
- 16+ reusable parts