Nextra
Tutorial- 0. Start a new project 🆕
- 1. Organize Your Content 📁
- 2. Add Authentication 🔐
- 3. Protecting routes
- 4. Generating private routes
- 5. Moving source files to
src
folder - 6. Adding custom theme
- 7. Conditional rendering on components
- 8. Linting the
Markdown
files
- Star the repo!
Important
While this tutorial was initially written for v2
of Nextra
,
most of the content is still relevant for v3
.
We make sure to highlight the differences
throughout the tutorial.
Tip
Some of the info in these notes
can also be found in the
official Nextra
docs:
nextra.site/docs
We recommend going through the docs
if you get stuck
or want a more in-depth understanding of the framework.
When using Nextra
,
you first have to choose:
either use a default
theme
or customize your own.
The vast majority of people will use the default
theme.
However, if you need a specific look & feel,
you may have to create your own theme.
We learned the hard way that it's much easier
to start with a custom theme
than using a default
one and attempt to change it afterwards ...
see:
nextra#2926
For this demo,
we'll start with the
default
theme
and change it as needed.
Before creating a project,
first install the dependencies
using
pnpm
:
pnpm add next react react-dom nextra nextra-theme-docs
This will create a package.json
file
and install the dependencies under /node_modules
.
Next add the following scripts
to package.json
to run the app:
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
},
Your package.json
will look similar to:
{
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^14.2.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"nextra": "^2.13.4",
"nextra-theme-docs": "^2.13.4"
}
}
Next, create a Nextra
config file
called
next.config.js
and add the following code to it:
const withNextra = require('nextra')({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.jsx',
defaultShowCopyCode: true
})
module.exports = withNextra()
This defines the global configuration for the project.
e.g:
defaultShowCopyCode: true
,
which will add a copy
button to code snippets.
Lastly, we need to create
a corresponding
theme.config.jsx
file in project root directory.
It is used to configure the Nextra
site theme.
Add the following code to it:
import { DocsThemeConfig } from "nextra-theme-docs";
const config: DocsThemeConfig = {
logo: <span>Nextra Docs</span>,
project: {
link: 'https://github.com/shuding/nextra'
}
}
export default config
Save the file and continue.
Tip
Full theme config docs: nextra.site/docs/docs-theme.
Given that
Nextra
is a file-based system framework
(based on Next.js
),
we create docs
under the /pages
directory.
Create a file with the path
pages/index.mdx
and type/paste the following markdown:
# Welcome to our docs!
Hello, world!
Note
mdx
files are markdown files
that allow you to write
JSX
markup.
Meaning you can import components
and embed them within your markdown files.
Let's see the first page; Run:
pnpm run dev
and visit localhost:3000 in your web browser You should see your page!
Congrats! You've just set up your docs site!
Now let's write some content! We are going to organize our documentation so it's easier to navigate.
In Nextra
,
the site and page structure can be configured via
_meta.json
files.
These files will affect the layout of the theme,
especially the sidebar/navigation bar.
For example, the title and order of a page shown in the sidebar
should be configured in the _meta.json
file as key-value pairs.
Create the following structure in your project.
pages
|_ api_reference
|_ _meta.json
|_ about.mdx
|_ _meta.json
|_ about.mdx
|_ contact.mdx
|_ index.mdx
Define the pages _meta.json
:
{
"index": "My Homepage",
"contact": "Contact Us",
"api_reference": "API Reference",
"about": "About Us"
}
And in the nested _meta.json
file,
inside api_reference
.
{
"about": "about"
}
Group pages together in directories to create a hierarchy of pages.
For a directory to have its own page,
simply add an index.mdx
file in the directory.
If we need api_reference
to have an introductory page,
simply create the index.mdx
file:
pages
|_ api_reference
|_ _meta.json
|_ about.mdx
|_ index.mdx // added this
|_ _meta.json
|_ about.mdx
|_ contact.mdx
|_ index.mdx
Fill each .mdx
file with content
then run:
pnpm run dev
to see your pages organized!
1.1 External Links and Hidden Routes 🔗
Customize the _meta.json
to show external links
and hide some routes.
In the top-level _meta.json
,
change it to the following:
{
"index": "My Homepage",
"contact": "Contact Us",
"api_reference": "API Reference",
"about": "About Us",
"github_link": {
"title": "Github",
"href": "https://github.com/shuding/nextra",
"newWindow": true
}
}
This will add a new link to the sidebar that,
once clicked,
will redirect the person to the Github
page.
We can also hide it;
add the display
property and set it to hidden
,
just like in CSS
!
{
"index": "My Homepage",
"contact": "Contact Us",
"api_reference": "API Reference",
"about": "About Us",
"github_link": {
"title": "Github",
"href": "https://github.com/shuding/nextra",
"newWindow": true,
"display": "hidden"
}
}
The navbar
is a great way to make key content visible.
Show pages (links) in the navbar instead of sidebar
using the "type": "page"
property
in the _meta.json
file.
To add API Reference
to the navbar,
update the top-level _meta.json
to:
{
"index": {
"title": "Homepage",
"type": "page",
"display": "hidden"
},
"api_reference": {
"title": "API Reference",
"type": "page"
},
"about": {
"title": "About Us",
"type": "page"
},
"contact": {
"title": "Contact Us",
"type": "page"
},
"github_link": {
"title": "Github",
"href": "https://github.com/shuding/nextra",
"newWindow": true,
"display": "hidden"
}
}
This will make every single top-level file
a page on the navbar.
We've hidden the index.mdx
,
as it is rendered by default when we enter the site
and we can return to it if we click on the website's logo.
Inside api_reference
,
create a new file called person.mdx
and write whatever you want in it.
Let's change the _meta.json
file inside this directory
to the following.
{
"about": "about",
"---": {
"type": "separator"
},
"person": "person"
}
The "---"
with `"type": "separator"
adds a separator between the two sidebar items.
If you run pnpm run dev
,
you will see that your site is organized differently.
The top-level pages are in the navbar,
and you can check the sidebar inside the api_reference
folder contents.
Awesome! 🎉
The basic docs site with organized content is working!
With the website working,
we can add authentication
to let people
sign in
and visit private pages.
Using
Auth.js
to streamline the authentication process.
This framework was previously working solely on Next.js
projects,
but this new v5
release extends their compatibility to other frameworks.
Let's add it to our project!
Note
At the time of writing,
only the beta
version of auth.js
is working.
We'll update this doc when a full stable release is announced.
pnpm add next-auth@beta
Next create the secret
which is used by auth.js
to encrypt token and e-mail verification hashes.
Run:
npx auth secret
Your terminal will show this:
Need to install the following packages:
[email protected]
Ok to proceed? (y) y
Secret generated. Copy it to your .env/.env.local file (depending on framework):
AUTH_SECRET=<your_secret>
Copy the secret and create a file called .env.local
.
e.g:
AUTH_SECRET="xYZIaxb3/4vWVFHO91ei850HX1c+4xt4LDg+HrEua2g="
.env.local
is where all
environment variables
are stored for authentication.
Now create the config files for Auth.js
to work!
Creating a file called auth.ts
at the root
and add the following code:
import NextAuth from "next-auth";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [],
});
Then, create the following folders from the root of the project -
/app/api/auth/[...nextauth]/
.
Nextra
only works in the
Pages Router.
Authentication with Auth.js
needs the
App Router to work.
For this, we need to create the folder hierarchy we've just mentioned.
After creating the folders,
create a file called route.ts
inside `[...nextauth].
import { handlers } from "@/auth" // Referring to the auth.ts we just created
export const { GET, POST } = handlers
Add a file called middleware.ts
at the root of the project.
This is to keep the session alive,
this will update the session expiry every time its called.
export { auth as middleware } from "@/auth"
Note
If your imports are complaining about
not being able to find the @auth
module,
Create a tsconfig.json
file at the root of the project
with the following code:
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"app/lib/placeholder-data.js",
"scripts/seed.js"
],
"exclude": ["node_modules"]
}
That's it! We're ready to go!
You can now decide how you are going to authenticate the users into your application. You can either:
- choose an
OAuth
provider delegated authentication where you delegate through a third party. - or you can set up your own identity provider and take care of it yourself.
We're going to choose the former, because it's easier.
Auth.js
docs
explain it succinctly.
OAuth
services spend significant amounts of money, time, and engineering effort building abuse detection (bot-protection, rate-limiting), password management (password reset, credential stuffing, rotation), data security (encryption/salting, strength validation), and much more. It is likely that your application would benefit from leveraging these battle-tested solutions rather than try to rebuild them from scratch.
Authenticate people through GitHub
.
Let's add it to our providers
array inside auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github"
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [GitHub],
});
Next, create an OAuth
app.
Navigate to
github.com/settings/developers
and click on OAuth Apps
and click on New OAuth App
.
Fill out the information.
The default callback URL will be of the form of
[origin]/api/auth/callback/[provider]
.
While developing, you may use localhost
as the origin
.
e.g:
http://localhost:3000/api/auth/callback/github
In production it will be something like:
https://docs.company.com/api/auth/callback/github
After completing the form,
you will be redirected to the page of your newly created OAuth
app.
Click on Generate a new client secret
.
You will be shown the secret of the new app.
Do not close the page, you will need to copy this secret
and the shown client ID.
In your .env.local
file,
add these two copied strings, like so.
AUTH_SECRET=
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=
And that's all the configuration we need! Now it's time to authenticate people into our app!
With all the configuration out of the way, it's time to a way for people to sign in and sign out of our Nextra website.
Before continuing, let's change our site's structure a little bit. Make it like so.
pages
|_ reference_api // yes, change from 'api_reference' to 'reference_api'
|_ mega_private // add this new folder
|_ _meta.json
|_ hello.mdx
|_ _meta.json
|_ about.mdx
|_ mega_private.mdx // This is the index page of `mega_private` that will show on the sidebar. Write whatever.
|_ users.mdx
|_ _meta.json
|_ about.mdx
|_ api_reference.mdx
|_ contact.mdx
|_ index.mdx
Important
Change the root _meta.json
file pertaining to "api_reference"
to "reference_api"
, and the folder as well.
This is important because we are going to have a middleware
that is going to match routes to perform authorization checks.
Next.js
projects usually have APIs under /api
,
which means the middleware was not going to perform verifications on anything
under api
.
Create a new folder mega_private
inside api_reference
.
We'll use this later.
You can write whatever you want in it.
Keep the _meta.json
inside mega_private
simple, like so.
{
"hello": "Hello page"
}
Great!
next-auth
provides a set of built-in pages
for people to go through their authentication journey
(sign in, sign up, sign out, error, etc...).
Although you can customize your own pages,
we are going to leverage these built-in pages
to keep the tutorial simple.
To allow people to sign in,
let's create a component to be shown in pages/index.mdx
,
the root page of the site.
For this, create a folder called components
on the root of the project.
Inside of components
, create LoginOrUserInfo
,
and inside this one create with the path:
components/LoginOrUserInfo/index.tsx
and code:
import { signOut, signIn } from "next-auth/react";
import { DefaultSession } from "next-auth";
export function LoginOrUserInfo({ session } : Readonly<{session: DefaultSession}>) {
if (session?.user) {
return (
<div>
<span>
Welcome <b>{session.user.name}</b>
</span>{" "}
<br />
<button onClick={() => signOut()}>SIGN OUT</button>
</div>
);
} else {
return <button onClick={() => signIn()}
class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
SIGN IN</button>;
}
}
This exports a function LoginOrUserInfo
that receives Session
object (from next-auth
).
Inside this component, what we do is really simple:
show his name and a SignOut
button if they are logged in;
otherwise, show a button to SignIn
.
Leveraging both signIn
and signOut
functions from next-auth
.
Warning
By creating these nested folders,
we have to update the tsconfig.json
file to encompass
files that are nested on more than two levels.
"include": [
"next-env.d.ts",
"*/**/*.ts", // add this line
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"app/lib/placeholder-data.js",
"scripts/seed.js"
],
Now let's use our newly created component.
Go to pages/index.mdx
and add the following code to the top.
import { auth } from "@/auth.ts"
import { useData } from 'nextra/data'
import Link from 'next/link'
import { signOut } from "next-auth/react"
import { LoginOrUserInfo } from "@/components/LoginOrUserInfo"
export async function getServerSideProps(ctx) {
const session = await auth(ctx)
return {
props: {
// We add an `ssg` field to the page props,
// which will be provided to the Nextra `useData` hook.
ssg: {
session
}
}
}
}
export const Info = () => {
// Get the data from SSG, and render it as a component.
const { session } = useData()
return <LoginOrUserInfo session={session}/>
We are using getServerSideProps
to fetch the session from the auth
function from next-auth
.
For more information about this inside Nextra
,
visit https://nextra.site/docs/guide/ssg.
We need to return the session inside an object props
and adding a named property called ssg
with the data we want to send over the components we want to display.
Nextra
retrieves this data using the useData()
hook.
In the Info
component, we fetch the session
object
from the auth
and pass it to the component
we've created <LoginOrUserInfo>
.
All that's left is using this component in our .mdx
file.
Simply add <Info/>
(the component we've created inside .mdx
)
wherever you want in the page1
If you run the application, you should be able to sign in and sign out!
We now have access to JWT
tokens in our application,
where GitHub
's OAuth
provider is providing them for us.
Now let's start using it to start protecting routes!
We now have a basic authentication flow in our site,
with GitHub
providing and managing the tokens for our application for us.
It's in our interest to restrict certain routes to specific users
with specific roles if we ever want to make our awesome documentation site
available to different clients.
Normally, when developing applications that follow the normal
Frontend -> Backend -> Database
convention,
you usually want to have security measures
as close to the data as possible.
With React Server Components
being introduced in Next.js
,
the line between server and client gets blurred.
Data handling is paramount in understanding where information is processed
and subsequently made available.
So we always have to be careful to know
how authentication and how protecting routes is implemented
and where it is processed,
so it's not subject to malicious actors.
Note
To learn more about Data Access Layers, please visit https://x.com/delba_oliveira/status/1800921612105011284 and https://nextjs.org/blog/security-nextjs-server-components-actions. These links provide great insights on how to implement a Data Access Layer to better encapsulate your data and minimize security pitfalls.
For this purpose,
because Nextra
statically generates pages,
we are going to be using middleware
to protect the routes.
next-auth
warns people to
on middleware exclusively for authorization and to always ensure that the session is verified as close to your data fetching as possible.
While that is true
(and a reiteration of what was aforementioned),
protecting the routes in our application
through middleware
is optimistically secure enough
(as it runs on the server-side),
as it runs on every request,
including prefetched routes.
Additionally, Next.js
recommends doing so.
"This is important for keeping areas like the user dashboard protected while having other pages like marketing pages be public. It's recommended to apply Middleware across all routes and specify exclusions for public access.
As per Next.js
's convention,
to add this middleware,
we need to create a middleware.ts
file at the root of the project.
Create it and add the following code to it:
// middleware.ts
"server only";
import { auth } from "@/auth";
import { NextResponse } from "next/server";
// !!!! DO NOT CHANGE THE NAME OF THE VARIABLE !!!!
const privateRoutesMap: any = {};
export default auth(async (req, ctx) => {
const currentPath = req.nextUrl.pathname;
const isProtectedRoute = currentPath in privateRoutesMap
if(isProtectedRoute) {
// Check for valid session
const session = req.auth
// Redirect unauthed users
if(!session?.user || !session.user.role) {
return NextResponse.redirect(new URL('/api/auth/signin', req.nextUrl))
}
// Redirect users that don't have the necessary roles
const neededRolesForPath = privateRoutesMap[currentPath]
if(!(session.user.role && neededRolesForPath.includes(session.user.role))) {
return NextResponse.redirect(new URL('/api/auth/signin', req.nextUrl))
}
}
return NextResponse.next()
});
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}
Let's break down what we just wrote:
privateRoutesMap
is an object/map that will have the path askey
and the array of roles needed to access the path asvalue
. This variable will be changed on build-time, so do not change its name!- we export
auth
as default frommiddleware.ts
. This function is called before every route server-side. Inside it, we check the path and the session cookie. With these, we can know if the route is protected or not. If it is, we check if the person can access it with the given role. If any of these conditions fail, the person is redirected to the Sign In page. - we also export a
config
object, with amatcher
property. We useRegEx
to configure which paths we want the middleware to run on.
Note
Your Typescript
compiler may be complaining
because role
is not a property inside user
.
Don't worry, we'll fix this now.
middleware.ts
assumes the JWT
token to have a role
in order to do role-based authorization.
For it to have access to it
(and throughout the whole application),
we ought to head over to auth.ts
and do additional configuration.
Open auth.ts
and change it the following.
// auth.ts
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
GitHub({
profile(profile) {
// GitHub's OAuth apps don't allow you to define roles.
// So `profile` here doesn't have a `role` property.
// But on other providers, you'd add the role here through it.
return {
id: profile.id.toString(),
name: profile.name ?? profile.login,
email: profile.email,
image: profile.avatar_url,
role: "user",
};
},
}),
],
callbacks: {
jwt({ token, user, account, profile }) {
// Normally, it would be like this
// if(user) return {...token, role: token.role}
// return token
// But because Github's provider is not passing the role
// (it should, according to https://authjs.dev/guides/role-based-access-control#with-jwt -
// maybe it's because v5 is still in beta), we're just gonna append it every time
return {...token, role: "user"}
},
session({ session, token }) {
session.user.role = token.role;
return session;
},
},
});
As usual, let's break it down!
- the
GitHub
provider accepts aprofile()
callback. In this callback, we receive aProfile
returned by theOAuth
provider (in our case, it'sGitHub
). We can return a subset using the profile's information to define user's information in our application. By default, it returns theid
,email
,name
,image
, but we can add more. That's what we did, by adding therole
property.
Note
If you're using a custom provider,
it is your responsibility to return the role so you can capture it in
your Next.js
/Nextra
application.
- we defined
callbacks
to add the role to ourJWT
tokens andsession
cookies. In order, thejwt
callback receives the user information we've defined earlier. We append therole
to the token. Afterwards, thesession
callback is invoked, where we use therole
property we've just appended to thetoken
and add it to thesession
cookie.
Note
In our code, we hardcode it every time.
It is because, at the time of writing,
next-auth
is releasing a v5
,
which has some bugs,
including the GitHub
provider not properly downstreaming the user
to the jwt
and session
callbacks.
They receive the user
as undefined
.
Unfortunately, this not an isolated occurrence. https://stackoverflow.com/questions/76986309/nextauth-nextjs-13-unable-to-add-user-role describes our exact scenario and doesn't have an answer 😕.
Here are a few more examples. Hopefully it will be resolved in time:
- nextauthjs/next-auth#9836
- nextauthjs/next-auth#9609
- https://stackoverflow.com/questions/72073321/why-did-user-object-is-undefined-in-nextauth-session-callback
- nextauthjs/next-auth#8456
If you're using a custom provider, it seems this is unlikely to happen to you. In the links above, people found solutions using custom providers and not the in-built ones, like we are using in this demo.
Important
To make the experience better for the user, we can implement a refresh token rotation system, where once a token expires, the application automatically refreshes it instead of asking the user to sign in again.
Read https://authjs.dev/guides/refresh-token-rotation to implement this with next-auth
.
But that's not all!
Because we are effectively
extending the properties of the User
,
we can augment it through types,
so Typescript
doesn't complain about role
being an undefined
property.
In the same auth.ts
file,
add the following code at the top.
import GitHub from "next-auth/providers/github";
import NextAuth, { type DefaultSession } from "next-auth";
// we don't use these but we need to import something so we can override the interface
import { DefaultJWT } from "next-auth/jwt";
import { AdapterSession } from 'next-auth/adapters';
// We need to add the role to the JWT inside `NextAuth` below, so the `middleware.ts` can have access to it.
// The problem is that it wasn't added this `role` custom field, even if we defined it in `auth.ts`.
// Apparently, the problem is with the types of `next-auth`, which we need to redefine.
// See https://stackoverflow.com/questions/74425533/property-role-does-not-exist-on-type-user-adapteruser-in-nextauth
// and see https://authjs.dev/getting-started/typescript#module-augmentation.
declare module "next-auth" {
interface Session extends DefaultSession {
/**
* By default, TypeScript merges new interface properties and overwrites existing ones.
* In this case, the default session user properties will be overwritten,
* with the new ones defined above. To keep the default session user properties,
* we need to add them back into the newly declared interface.
*/
user: DefaultSession["user"] & {
role?: string;
};
}
interface User {
// Additional properties here:
role?: string;
}
}
declare module "next-auth/adapters" {
interface AdapterUser {
// Additional properties here:
role?: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
role?: string;
}
}
And that's it! 🎉 Give yourself a pat on the back!
Now that we have the authentication and authorization set up, it's time to finally use it.
As you may have noticed by now,
Nextra
statically generates the page for you
following the structure and the _meta.json
files
that you define in your project.
These changes are then reflected in your navbar and sidebar.
However, there is no way for us to define private routes
and use them at build-time,
like you do when generating the pages with Nextra
.
Unfortunately, Nextra
doesn't have this feature in place,
and authentication is not something they had in mind to implement
for a framework that was primarily meant for public-facing documentation.
However, it is possible to do this,
considering what was discussed in
3.1 A word about middleware.ts
,
as long as we have access to the private routes at build-time.
To accomplish this, we're using ts-morph
,
a package that will allow us to manipulate Typescript source files.
We will create a script with ts-morph
that manipulates the const privateRoutesMap
inside middleware.ts
.
This script will populate the const
with all the private routes
and the needed roles to access them.
Let's crack on!
Install ts-morph
, ts-node
(to run the script we're implementing)
and fast-glob
(to traverse the file system).
pnpm add ts-morph ts-node fast-glob
After installing these dependencies,
we will have to adjust our tsconfig.json
file
so we can run our script independently.
Below the exclude
property, in the last line,
add the following piece of code.
"exclude": ["node_modules"],
// These are only used by `ts-node` to run the script that generates the private routes.
// These options are overrides used only by ts-node, same as the --compilerOptions flag and the TS_NODE_COMPILER_OPTIONS environment variable
"ts-node": {
"compilerOptions": {
"module": "commonjs"
}
},
Great! Let's start implementing our script now.
In the root of the project,
create a file called generatePrivateRoutes.ts
.
Add the following code.
import { Project } from "ts-morph";
import path from "path";
import fs from "fs";
import { globSync } from "fast-glob";
// - - - - - - - - - - - - - - - - - - - - - - -
// `middleware.ts` is changed by executing the code below.
const CONST_VARIABLE_NAME = "privateRoutesMap"; // Name of the constant inside `middleware.ts` to be manipulated
const DIRECTORY = "pages"; // Directory to look for the routes (should be `pages`, according to Nextra's file system)
export function changeMiddleware() {
// Get private routes
const pagesDir = path.join(__dirname, DIRECTORY);
const privateRoutes = getPrivateRoutes(pagesDir);
// Initialize the project and source file
const project = new Project();
const sourceFile = project.addSourceFileAtPath(path.resolve(__dirname, "middleware.ts"));
// Find the variable to replace and change it's declaration
const variable = sourceFile.getVariableDeclaration(CONST_VARIABLE_NAME);
if (variable) {
variable.setInitializer(JSON.stringify(privateRoutes));
sourceFile.saveSync();
} else {
console.error("Variable not found in `middleware.ts`. File wasn't changed.");
}
}
export default {
changeMiddleware: () => changeMiddleware()
}
The code is fairly simple.
We get the private routes by calling a function called getPrivateRoutes()
(which we will implement shortly).
Then, we find the middleware.ts
file
and find the variable we want to change.
If we do find it, we change its initialization
to the private routes map we retrieved earlier.
Otherwise, we log an error.
Now, it's time to add the function that will retrieve the private routes!
Our function getPrivateRoutes
will need to recursively iterate
over a given folder directory and find a way to know which routes are private.
As of now, there's no way of doing so.
That's why we're going
to define a new property inside _meta.json
files.
We need to tell our function which pages/routes are private.
To do this, we are going to use the _meta.json
files
that come with Nextra
.
To achieve this,
we are going to allow people to declare a route as private
by adding a private
property to a route defined in the respective _meta.json
.
// pages/_meta.json
{
"index": {
"title": "Homepage",
"type": "page",
"display": "hidden"
},
"reference_api": {
"title": "API Reference",
"type": "page",
"private": { // We add this property to make `reference_api` private
"private": true,
"roles": ["user"]
}
},
"about": {
"title": "About Us",
"type": "page"
},
"contact": {
"title": "Contact Us",
"type": "page"
},
"github_link": {
"title": "Github",
"href": "https://github.com/shuding/nextra",
"newWindow": true,
"display": "hidden"
}
}
As you can see, we've added a property called private
to the reference_api
route,
where we define if it's private or not by boolean in the private
property
and where we define the roles that can access it
in the roles
property.
Alternatively, people can define private routes by simply
passing "private": true
, instead of passing an object.
In these cases, the roles
empty will default to an empty array,
"reference_api": {
"title": "API Reference",
"type": "page",
"private": true // Simpler version
},
We'll take into account both of these scenarios.
Okay, now let's start coding our function!
Inside generatePrivateRoutes.ts
,
add this function above the ts-morph
code
we've written earlier.
// Types for the `_meta.json` structure
type PrivateInfo = {
private: boolean;
roles?: string[];
};
type MetaJson = {
[key: string]: string | any | PrivateInfo;
};
type PrivateRoutes = {
[key: string]: string[];
};
/**
* This function looks at the file system under a path and goes through each `_meta.json` looking for private routes recursively.
* It is expecting the `_meta.json` values of keys to have a property called "private" to consider the route as private for specific roles.
* If a parent is private, all the children are private as well. The children inherit the roles of their direct parent.
* @param pagesDir path to recursively look for.
* @returns map of private routes as key and array of roles that are permitted to access the route.
*/
function getPrivateRoutes(pagesDir: string): PrivateRoutes {
let privateRoutes: PrivateRoutes = {};
// Find all _meta.json files recursively
const metaFiles = globSync(path.join(pagesDir, "**/_meta.json"));
// Variable to keep track if parent is private on nested routes and its role
const rootPrivateSettings: { [key: string]: { private: boolean; roles: string[] } } = {};
// Iterate over the found meta files
for (const file of metaFiles) {
// Get the path of file and read it
const dir = path.dirname(file);
const metaJson: MetaJson = JSON.parse(fs.readFileSync(file, "utf-8"));
// Iterate over the key/value pairs of the "_meta.json" file
for (const [key, meta] of Object.entries(metaJson)) {
const route = path.join(dir, key).replace(pagesDir, "").replace(/\\/g, "/");
// Check if the current meta has a "private" property
if (meta.private !== undefined) {
if (typeof meta.private === "boolean") {
if (meta.private) {
privateRoutes[route] = [];
rootPrivateSettings[dir] = { private: true, roles: [] };
}
}
// If the "private" property is an object with possible roles
else if (meta.private.private === true) {
const roles = meta.private.roles ? meta.private.roles : [];
privateRoutes[route] = roles;
rootPrivateSettings[dir] = { private: true, roles: roles };
}
} else {
// Check if the parent folder is private and inherit roles
const parentDir = path.resolve(dir, "..");
if (rootPrivateSettings[parentDir] && rootPrivateSettings[parentDir].private) {
const parentRoles = rootPrivateSettings[parentDir].roles;
privateRoutes[route] = parentRoles;
}
}
}
}
// Now let's just do a second pass to clean-up possible unwanted/invalid routes
for (const route of Object.keys(privateRoutes)) {
const fullPath = path.join(pagesDir, route);
const lastSegment = route.split("/").pop();
// Remove separators or any route that doesn't correspond to an existing file/directory
if (lastSegment === "---") {
delete privateRoutes[route];
continue;
}
// Check for the existence of .mdx file
const mdxPath = `${fullPath}.mdx`;
if (!fs.existsSync(fullPath) && !fs.existsSync(mdxPath)) {
delete privateRoutes[route];
}
}
return privateRoutes;
}
Whoa, that's a lot! The code is fairly documented so you can follow along easier. But the gist of it is:
- we use
fast-glob
to find all the_meta.json
files recursively. - we iterate over the
_meta.json
files.- in each
_meta.json
, we look over the key-value pairs to construct the route. - in each key-value, we check for the
private
property and resolve its boolean. - while iterating, we keep track of the routes and check if the parent is also private. If the parent is private, the child must be private too. It also inherits the direct parent's roles.
- in each
- after iteration, we do a second pass to clean-up possible invalid routes.
- we return a map of
private route
as key androles array
as value.
And that's it!
For Nextra v3
,
we need to make a few changes.
Because _meta.json
files now become _meta.ts
files,
we're going to change the getPrivateRoutes
function.
export function getPrivateRoutes(pagesDir: string): PrivateRoutes {
let privateRoutes: PrivateRoutes = {};
// Find all _meta.ts files recursively
const metaFiles = globSync(path.join(pagesDir, "**/_meta.ts"));
// Variable to keep track if parent is private on nested routes and its role
const rootPrivateSettings: {
[key: string]: { private: boolean; roles: string[] }
} = {};
// Initialize ts-morph Project for reading the _meta.ts files
const project = new Project();
// Iterate over the found meta files
for (const file of metaFiles) {
// Get the path of the file and parse it using ts-morph
const dir = path.dirname(file);
const sourceFile = project.addSourceFileAtPath(file);
// Get the default export assignment from the _meta.ts file
const defaultExport = sourceFile.getExportAssignments();
if (!defaultExport) {
console.error(`No export assignment found in ${file}`);
continue;
}
const metaObject = defaultExport[0].getExpression();
// If the expression is an object literal, we can process it
if (metaObject?.getKind() === SyntaxKind.ObjectLiteralExpression) {
const objectLiteral = metaObject as ObjectLiteralExpression;
// Iterate over the properties of the object literal
objectLiteral.getProperties().forEach(prop => {
if (prop.getKind() === SyntaxKind.PropertyAssignment) {
const propertyAssignment = prop as PropertyAssignment;
const key = propertyAssignment.getName();
const initializer = propertyAssignment.getInitializer();
if (!key || !initializer) {
return;
}
const route = path.join(dir, key).replace(pagesDir, "").replace(/\\/g, "/");
// Check if the current meta has a "private" property
if (initializer.getKind() === SyntaxKind.ObjectLiteralExpression) {
const metaInitializer = initializer as ObjectLiteralExpression;
// Check for "private" property inside the object
const privateProp = metaInitializer.getProperty("private") as PropertyAssignment;
if (privateProp) {
const privateValue = privateProp.getInitializer();
if (privateValue?.getKind() === SyntaxKind.TrueKeyword) {
privateRoutes[route] = [];
rootPrivateSettings[dir] = { private: true, roles: [] };
}
// If the "private" property is an object with possible roles
else if (privateValue?.getKind() === SyntaxKind.ObjectLiteralExpression) {
const privateObject = privateValue as ObjectLiteralExpression;
const rolesProp = privateObject.getProperty("roles") as PropertyAssignment;
const roles = rolesProp ? rolesProp.getInitializer()?.getText().replace(/[\[\]\s"]/g, "").split(",") as string[] : [] as string[];
privateRoutes[route] = roles;
rootPrivateSettings[dir] = { private: true, roles: roles };
}
}
} else {
// Check if the parent folder is private and inherit roles
const parentDir = path.resolve(dir, "..");
if (rootPrivateSettings[parentDir] && rootPrivateSettings[parentDir].private) {
const parentRoles = rootPrivateSettings[parentDir].roles;
privateRoutes[route] = parentRoles;
}
}
}
});
}
}
// Second pass to clean-up possible unwanted/invalid routes
for (const route of Object.keys(privateRoutes)) {
const fullPath = path.join(pagesDir, route);
const lastSegment = route.split("/").pop();
// Remove separators or any route that doesn't match existing file/directory
if (lastSegment === "---") {
delete privateRoutes[route];
continue;
}
// Check for the existence of .mdx file
const mdxPath = `${fullPath}.mdx`;
if (!fs.existsSync(fullPath) && !fs.existsSync(mdxPath)) {
delete privateRoutes[route];
}
}
return privateRoutes;
}
Now that we have our handy-dandy script ready,
we need to make sure it always gets executed before running our application,
whether it is being built for production
or compiling to be running on localhost
.
To do this, we only need to change our package.json
file.
Head over there and change the scripts, like so:
"scripts": {
"private-route-gen": "ts-node -e \"import gen from './src/generatePrivateRoutes'; gen.changeMiddleware()\"",
"dev": "npm run private-route-gen && next",
"prebuild": "npm run private-route-gen",
"build": "next build",
"start": "next start"
},
We've created a private-route-gen
that uses ts-node
to run our script.
This newly added script is executed
when running pnpm run dev
(running the app locally)
and pnpm run build
.
We've added it to the prebuild
script
so it executes before production builds.
And that's it! Now every time you run your application, we are sure that the private routes are correctly materialized!
Hurray! 🎉
Note
You may get an error when building the project saying:
Error validating _meta.json file for "reference_api" property.
Unrecognized key(s) in object: 'private'
This is expected, since Nextra
doesn't know what the private
property is.
This doesn't affect the performance of the application,
it's simply a warning.
Before proceeding, let's do some cleaning up 🧹.
Right now, we have some source files
(like auth.ts
, middleware.ts
, generatePrivateRoutes.ts
)
mixed with several configuration files at root level.
Luckily for us, Next.js
supports adding a src
folder
so we can keep the root directory focused on configuration files
and the src
folder to source files.
Let's move our source code to a src
folder!
Start by creating it at root level.
Then, move the following items into it:
- the
app
folder. - the
components
folder. - the
pages
folder. auth.ts
,middleware.ts
andgeneratePrivateRoutes.ts
files.
Now we have to change some imports. Check the following changes in each file so everything works again!
// src/pages/index.mdx
import { auth } from "@/src/auth.ts" // changed from `@/auth.ts`
import { useData } from 'nextra/data'
import Link from 'next/link'
import { signOut } from "next-auth/react"
import LoginOrUserInfo from "@/src/components/LoginOrUserInfo" // changed from `@/components/LoginOrUserInfo`
// src/middleware.ts
"server only";
import { auth } from "@/src/auth"; // changed from `@/auth.ts`
import { NextResponse } from "next/server";
// tests/unit/LoginOrUserInfo.test.tsx
import { render, screen } from "@testing-library/react";
import LoginOrUserInfo from "@/src/components/LoginOrUserInfo"; // changed from `@/components/LoginOrUserInfo`
import { DefaultSession } from "next-auth";
import { signOut, signIn } from "next-auth/react";
// package.json
"private-route-gen": "ts-node src/generatePrivateRoutes.ts",
And you're sorted!
We now have all our source files inside src
,
the tests inside tests
,
and all the configuration files at root level.
Now that we've protected some routes through the middleware.ts
file,
we need to go a bit further.
It doesn't make sense for public people to see the private routes,
either be it on the sidebar or on the navbar.
For this, we can go about this with two options:
-
we customize the default theme through
theme.config.jsx
(https://nextra.site/docs/docs-theme/theme-configuration#customize-the-navbar), where we check each sidebar title and hide it and override the whole navbar. -
use the code from the
nextra-theme-docs
to keep the default theme, use it as acustom-theme
intheme.config.jsx
and try to conditionally render each link according to the person's role.
Although you can go with Option 1
for simplicity sake,
we are going with Option 2
for three main reasons:
- we want to keep the same look n' feel of the application as it stands.
- we don't want to re-implement and waste the time trying to do so.
- we want to have access to the
private
property we've defined inside_meta.json
files, which can only be made through augmenting the types of thenextra-docs-theme
source code.
With this in mind, let's do this!
Download the directory from https://github.com/dwyl/nextra-demo/tree/34a6327e00b941b50dffdbbed99ab6bf294511a4/theme.
This directory is a version of nextra-theme-docs
with a few modifications (they are explained in the README.md
inside the directory).
Long story short, the differences are:
- we've imported some
nextra
components from the original package and placed it inside the theme. - moved some code from
src/constants.tsx
tosrc/contexts/config.tsx
. This is because, as is, the code threw aReferenceError: Cannot access 'DEFAULT_THEME' before initialization
error. - removed
tailwind.config.js
andpostcss.config.js
. We'll be using these on the root of the project instead.
After downloading this directory,
put all of the downloaded code inside a new directory called theme
.
For this to work,
we'll have to install TailwindCSS
on our project.
This is what the custom theme depends on to properly render their components.
For this, open the terminal and type pnpm install tailwindcss postcss autoprefixer
.
Next up, let's create two files:
tailwind.config.js
and postcss.config.js
.
These two files are from the original theme.
// tailwind.config.js
const colors = require('tailwindcss/colors')
const makePrimaryColor =
l =>
({ opacityValue }) => {
return (
`hsl(var(--nextra-primary-hue) var(--nextra-primary-saturation) ${l}%` +
(opacityValue ? ` / ${opacityValue})` : ')')
)
}
/** @type {import('tailwindcss').Config} */
module.exports = {
prefix: 'nx-',
content: [
'./theme/src/**/*.tsx',
'./theme/src/nextra_icons/*.tsx',
'./theme/src/nextra_components/*.tsx'
],
theme: {
screens: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px'
},
fontSize: {
xs: '.75rem',
sm: '.875rem',
base: '1rem',
lg: '1.125rem',
xl: '1.25rem',
'2xl': '1.5rem',
'3xl': '1.875rem',
'4xl': '2.25rem',
'5xl': '3rem',
'6xl': '4rem'
},
letterSpacing: {
tight: '-0.015em'
},
colors: {
transparent: 'transparent',
current: 'currentColor',
black: '#000',
white: '#fff',
gray: colors.gray,
slate: colors.slate,
neutral: colors.neutral,
red: colors.red,
orange: colors.orange,
blue: colors.blue,
yellow: colors.yellow,
primary: {
50: makePrimaryColor(97),
100: makePrimaryColor(94),
200: makePrimaryColor(86),
300: makePrimaryColor(77),
400: makePrimaryColor(66),
500: makePrimaryColor(50),
600: makePrimaryColor(45),
700: makePrimaryColor(39),
750: makePrimaryColor(35),
800: makePrimaryColor(32),
900: makePrimaryColor(24)
}
},
extend: {
colors: {
dark: '#111'
}
}
},
darkMode: ['class', 'html[class~="dark"]']
}
// postcss.config.js
/** @type {import('postcss').Postcss} */
module.exports = {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {},
'postcss-lightningcss': {
browsers: '>= .25%'
}
}
}
And that's it for the TailwindCSS
part of things!
Now, because our theme
directory has its own set of dependencies,
we need to tell our root project that information!
We want it to install and use its dependencies.
For this, we leverage pnpm
Workspaces
to install and update dependencies in a single pnpm install
command!
For this, we have to:
- give our root project's workspace a name.
We can do this by going to
package.json
and adding the line"name": "nextra"
on top. - create a
pnpm-workspace.yaml
file.
packages:
- '.'
- 'theme'
Now that we've installed TailwindCSS
,
let's use it in our application!
For our theme to work, we need to create a .css
file
and import it in our Pages Router
so it is used throughout our application pages.
To do this,
create a file inside src
- src/globals.css
.
/* Use the theme's styles CSS file */
@import "../theme/css/styles.css"
Easy enough, right?
We are simply using the theme
's styles in our own application's styles!
Now we just need to use it in our application.
To do this,
we are going to be using a custom app component,
which is called to initialize pages.
We are going to override it to inject our styles.
Inside src/pages
, create a file called _app.tsx
.
// These styles apply to every route in the application
import '@/src/globals.css'
import type { AppProps } from 'next/app'
export default function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
And that's it!
The very last thing we need to do,
is to tell Nextra
we want to use our custom theme.
For this, simply head over to next.config.js
and change the theme
property
to the directory of our newly created theme
directory.
const withNextra = require("nextra")({
theme: "./theme/src/index.tsx", // change here
themeConfig: "./theme.config.jsx",
defaultShowCopyCode: true
});
module.exports = withNextra();
If you run pnpm run dev
, you should be able to see the application running as before!
Now that we have our custom theme properly setup and added to our application, we now have the opportunity to do whatever we want with the components. In our case, it is extremely useful to conditionally render links of protected routes according to the logged user.
auth.js
provides us a few ways of authenticating people
and getting their session.
Next.js
is moving to a server-side
-first approach
with their app router
with the introduction of Server Components
.
So this should be our approach.
However, Nextra
does not yet support app router
.
This makes it so that the statically rendered pages are client components.
If we take a look at auth.js
's documentation,
we quickly realise that we can't use the auth()
call like we did in src/middleware.ts
inside our theme's components.
In fact, we can only use useSession()
to fetch the current session of the logged in person.
Note
We did try using the auth()
call,
but we were always prompted with the same error:
unhandledRejection: Error: `headers` was called outside a request scope. Read more: https://nextjs.org/docs/messages/next-dynamic-api-wrong-context
Reading the link in the error,
this happens because calling headers()
is made within the library,
which is out of the scope of the theme, making it impossible to do it "server-side".
Even after updating the handlers
inside app/api/auth/[...nextauth]/route.ts
and simply overriding the default theme through theme.config.jsx
,
the same error occurred.
While this may seem not like the ideal scenario,
it's important to stress that the routes are still protected
and that the issued JWT
are encrypted by default..
So, while very unlikely, the person may know some links exist,
but they can't access it,
as they are protected server-side through middleware.ts
.
Now that we know we're using the useSession()
hook to retrieve the person's current session,
let's make some changes to our code so we can use it!
The first thing we need to do
is to wrap our app with <SessionProvider>
.
This will ensure the session is accessible throughout the application.
Head over to src/pages/_app.tsx
,
and change it to the following.
// These styles apply to every route in the application
import '@/src/globals.css'
import type { AppProps } from 'next/app'
import { SessionProvider } from "next-auth/react"
export default function MyApp({
Component,
pageProps: { session, ...pageProps },
}: AppProps) {
return (
<SessionProvider session={session}>
<Component {...pageProps} />;
</SessionProvider>
)
}
Awesome!
Before proceeding, let's do some housekeeping 🧹. We know we are going to need two things:
- use the extended
User
interface that we augmented insideauth
. - use the
PrivateInfo
type insidesrc/generatePrivateRoutes.ts
to know whether a menu item is private or not.
Let's start with the first one.
Inside src/auth.ts
,
locate the augmented Session
interface.
Change it to the following.
import { ExtendedUser } from './types'; // added this
declare module "next-auth" {
interface Session extends DefaultSession {
user: ExtendedUser // changed here
}
interface User {
// Additional properties here:
role?: string;
}
}
Notice that we've created an ExtendedUser
interface
that is imported from types.ts
.
We haven't created this file.
So let's do it!
Inside src
, create a file called types.ts
.
import { type DefaultSession } from 'next-auth';
/**
* Holds types for the application (on the `src` side)
*/
// Types for the `_meta.json` structure
export type PrivateInfo = {
private: boolean;
roles?: string[];
};
export type MetaJson = {
[key: string]: string | any | PrivateInfo;
};
export type PrivateRoutes = {
[key: string]: string[];
};
// Types for auth
export type ExtendedUser = {
role?: string;
} & DefaultSession["user"];
Notice that we simply moved the extended user code
to ExtendedUser
inside this file.
In addition to this, we have also copied the types
from src/generatePrivateRoutes.ts
to here, as well.
All that is left to do is update src/generatePrivateRoutes.ts
to import these!
// generatePrivateRoutes.ts
import { PrivateRoutes, MetaJson } from './types';
// delete the types
// ...
And that's it! We're ready to rock and roll! 🎸
Let's start by conditionally rendering the links
inside the navbar
.
First let's go theme/src/types.ts
and add two additional types for the
PageItem
and MenuItem
types
that are used inside theme/src/components/navbar.tsx
.
Add the following lines.
// `theme/src/types.ts`
import type { MenuItem, PageItem } from 'nextra/normalize-pages'
import { PrivateInfo } from '../../src/types';
// Extends the PageItem and MenuItem with the `private` property
export type ExtendedPageItem = { private?: PrivateInfo } & PageItem;
export type ExtendedMenuItem = { private?: PrivateInfo } & MenuItem;
We are simply extending the PageItem
and MenuItem
types
with the PrivateInfo
interface that we moved earlier.
This way, we'll have access to the private
property when writing code
inside navbar.tsx
!
Speaking of which,
let's finally change the navbar.tsx
component!
Head over to theme/src/components/navbar.tsx
First, locate the NavBarProps
type
on top of the file and change it accordingly.
We are extending our items' types with the extended types we've defined before
so we can access the private
property.
export type NavBarProps = {
flatDirectories: Item[]
items: (ExtendedPageItem | ExtendedMenuItem)[]
}
Now we're ready to make some changes to the NavBar
function.
Head over to this function and use the useSession()
hook.
// theme/src/components/navbar.tsx
import { useSession } from "next-auth/react"
import { ExtendedPageItem, ExtendedMenuItem } from '../types';
import { ExtendedUser } from '../../../src/types';
export function Navbar({ flatDirectories, items }: NavBarProps): ReactElement {
const config = useConfig()
const activeRoute = useFSRoute()
const { menu, setMenu } = useMenu()
const {data, status: session_status} = useSession() // add this
const user = data?.user as ExtendedUser // add this
return (
...
)
Great!
We are now successfully using the useSession()
hook
and getting the authenticated (or not) person's session data.
Now we can leverage this data
to conditionally render the links inside our NavBar
!
Because the Session
object returned by useSession
also has a status
(that can be either "loading"
, "authenticated"
or "unauthenticated"
),
we will have to take this into account when changing
how the NavBar
renders the links.
Other than this,
all we have to do is block the rendering
of links that the user is not allowed to see.
Locate the return
of the NavBar
function
and implement the following changes.
return (
<div>
<div/>
<nav>
{config.logoLink ? (
<Anchor
href={typeof config.logoLink === 'string' ? config.logoLink : '/'}
className="nx-flex nx-items-center hover:nx-opacity-75 ltr:nx-mr-auto rtl:nx-ml-auto"
>
{renderComponent(config.logo)}
</Anchor>
) : (
<div className="nx-flex nx-items-center ltr:nx-mr-auto rtl:nx-ml-auto">
{renderComponent(config.logo)}
</div>
)}
{items.map(pageOrMenu => {
// Start of changes -------------------
// Wait until the session is fetched (be it empty or authenticated)
if(session_status === "loading") {
return null
}
// If it's a public user but the link is marked as private, hide it
if(session_status === "unauthenticated") {
if(pageOrMenu.private) return null
}
// If the user is authenticated
// and the page menu is protected or the role of the user is not present in the array, we block it
if(session_status === "authenticated" && user) {
if (pageOrMenu.private?.private) {
const neededRoles = pageOrMenu.private.roles || []
const userRole = user.role
if(!userRole || !neededRoles.includes(userRole)) {
return null
}
}
}
// End of changes -------------------
if (pageOrMenu.display === 'hidden') return null
// ...
})}
)
Let's break down what we just implemented:
- we check if the session status is
"loading"
. If so, we render nothing. - if the session status is
"unauthenticated"
and the route is private, we don't render the title for the person to see. - if the session status is
"authenticated"
, it means the person has a session and is logged in. If the route is private, we check if the person has the necessary role to see it. If they don't, we don't render it for the person to see.
And that's it!
We can test this behaviour!
If we run our application as is
(pnpm run dev
),
we'll see how everything is the same.
That's because our person has the "user"
role,
which is allowed under api_reference
.
However, if we head over to src/auth.ts
and change the role to something different...
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: providers,
callbacks: {
jwt({ token, user, account, profile }) {
return { ...token, role: "another_role" }; // changed here
},
session({ session, token }) {
session.user.role = token.role
return session;
},
},
});
And run the application again, you'll see that the title is hidden!
Hurray! 🎉
Because we're going to be using this feature of conditionally hiding links,
let's make it a function so we can use it in other places
(namely the sidebar
).
Inside theme/src/utils/render.tsx
,
add the following function.
This function is the same code that we just wrote inside navbar.tsx
.
import { ExtendedUser } from "../../../src/types";
import { ExtendedItem, ExtendedPageItem, ExtendedMenuItem } from "../types";
import { SessionContextValue } from "next-auth/react";
export function shouldLinkBeRenderedAccordingToUserRole(
session: SessionContextValue<boolean>,
item: ExtendedItem | ExtendedPageItem | ExtendedMenuItem
) {
const { data, status: session_status } = session;
const user = data?.user as ExtendedUser;
// Wait until the session is fetched (be it empty or authenticated)
if (session_status === "loading") return false;
// If it's a public user but the link is marked as private, hide it
if (session_status === "unauthenticated") {
if (item.private) return false;
}
// If the user is authenticated
// and the page menu is protected or the role of the user is not present in the array, we block it
if (session_status === "authenticated" && user) {
if (item.private?.private) {
const neededRoles = item.private.roles || [];
const userRole = user.role;
if (!userRole || !neededRoles.includes(userRole)) {
return false;
}
}
}
return true;
}
Great!
Now we just need to use it inside our navbar.tsx
file.
// theme/src/components/navbar.tsx
// ...
// Old code --
const {data, status: session_status} = useSession()
const user = data?.user as ExtendedUser
// New code --
const session = useSession()
// ...
// Old code --
if(session_status === "loading") return null
if(session_status === "unauthenticated") {
if(pageOrMenu.private) return null
}
if(session_status === "authenticated" && user) {
if (pageOrMenu.private?.private) {
const neededRoles = pageOrMenu.private.roles || []
const userRole = user.role
if(!userRole || !neededRoles.includes(userRole)) {
return null
}
}
}
// New code --
if(!shouldLinkBeRenderedAccordingToUserRole(session, pageOrMenu))
return null
Awesome! This makes things simple for the rest of our guide!
To conditionally render inside theme/src/components/sidebar.tsx
takes a bit more work.
This is because this component is used to display both the structure of the document inside each page
but also to showcase the menu in mobile.
If you take a look inside the sidebar.tsx
file,
you will find the following piece of code.
interface SideBarProps {
docsDirectories: PageItem[]
flatDirectories: Item[]
fullDirectories: Item[]
asPopover?: boolean
headings: Heading[]
includePlaceholder: boolean
}
These are the props that are passed on to the Sidebar
function component.
What's important is what docsDirectories
, flatDirectories
and fullDirectories
pertain to.
If we look at
nextra-demo/theme/src/index.tsx
Lines 121 to 140 in fd8f09a
normalizePages()
function
from the nextra/normalize-pages
package.
docsDirectories
are used in the sidebar menu when in desktop mode. You can find it on the left side of the screen, showing the structure of the page and of the parent directory.
Important
This array can be extended so it includes our custom private
property we've added to the _meta.json
files.
This can be done with the types we've defined earlier in theme/src/types.ts
.
fullDirectories
are used in the sidebar menu in mobile mode. The sidebar here takes on a role of both thenavbar
and the page contents. So, it will show the page routes alongside its children (hence the namefullDirectories
).
Important
This array can be extended so it includes our custom private
property we've added to the _meta.json
files.
This can be done with the types we've defined earlier in theme/src/types.ts
.
flatDirectories
are used inside the search component.
Important
This array can NOT be extended with the private
property we've added to the _meta.json
files, at least through normal augmentation.
To include the private
property in each item of this array,
we'll have to find another way.
For this section, we won't be needing to use this array, though.
Now that we have an idea what each xxxDirectories
paramater pertains to,
let's redefine them with our extended types.
Inside theme/src/components/sidebar.tsx
,
change it to the following.
// theme/src/components/sidebar.tsx
interface SideBarProps {
docsDirectories: ExtendedPageItem[] // they have the `private` property
flatDirectories: ExtendedItem[] // they DON'T have the `private` property (used for search)
fullDirectories: ExtendedItem[] // they have the `private` property
asPopover?: boolean
headings: Heading[]
includePlaceholder: boolean
}
Great!
We are using a new type ExtendedItem
that we haven't defined yet.
Let's do that inside theme/src/types.ts
, alongside the others.
// theme/src/types.tsx
export type ExtendedItem = { private?: PrivateInfo } & Item;
Awesome! 🥳
If we take a closer look inside navbar.tsx
,
you will see the Menu
function
is what renders the links
(it either receives fullDirectories
or docDirectories
arrays).
So this is the function where we want to make changes! 😊
First, let's change the MenuProps
to our extended types
so we have access to the private
property
we've defined inside our _meta.json
files.
interface MenuProps {
directories: ExtendedPageItem[] | ExtendedItem[] // change here
anchors: Heading[]
base?: string
className?: string
onlyCurrentDocs?: boolean
}
And now, inside the Menu
function,
simply do the same thing we've done in the navbar
!
function Menu({
directories,
anchors,
className,
onlyCurrentDocs
}: MenuProps): ReactElement {
const session = useSession()
return (
<ul className={cn(classes.list, className)}>
{directories.map(item => {
if(!shouldLinkBeRenderedAccordingToUserRole(session, item))
return null
return !onlyCurrentDocs || item.isUnderCurrentDocsTree ? (
item.type === 'menu' ||
(item.children && (item.children.length || !item.withIndexPage)) ? (
<Folder key={item.name} item={item} anchors={anchors} />
) : (
<File key={item.name} item={item} anchors={anchors} />
)
) : null
}
)}
</ul>
)
}
Note
In Nextra v3
, you will only need to add the following code to the Menu
function:
if(!shouldLinkBeRenderedAccordingToUserRole(session, item))
return null
We are calling the useSession()
hoook
and using the shouldLinkBeRenderedAccordingToUserRole()
function to render the links
according to the person role!
This function is a function that we create in /theme/src/utils/render.tsx
.
Change it so it has the following piece of code following:
import { ExtendedUser } from "../../../src/types";
import { ExtendedItem, ExtendedPageItem, ExtendedMenuItem } from "../types";
import { SessionContextValue } from "next-auth/react";
/**
* This functions tells if the user can see a given `navbar` or `sidebar` link item
* according to its role.
* @param session session from the user.
* @param item item (can either be a page item, menu item or normal item)
* @returns true if the link can be shown to the user. False if the user doesn't have authorization to see the link.
*/
export function shouldLinkBeRenderedAccordingToUserRole(
session: SessionContextValue<boolean>,
item: ExtendedItem | ExtendedPageItem | ExtendedMenuItem
) {
const { data, status: session_status } = session;
const user = data?.user as ExtendedUser;
// Wait until the session is fetched (be it empty or authenticated)
if (session_status === "loading") return false;
// If it's a public user but the link is marked as private, hide it
if (session_status === "unauthenticated") {
if (item.private) return false;
}
// If the user is authenticated
// and the page menu is protected or the role of the user is not present in the array, we block it
if (session_status === "authenticated" && user) {
if (item.private?.private) {
const neededRoles = item.private.roles || [];
const userRole = user.role;
if (!userRole || !neededRoles.includes(userRole)) {
return false;
}
}
}
return true;
}
And that's it!
Congratulations,
we've just conditionally rendered
all the private properties in both our sidebar
and navbar
!
You may notice that the mega_private
directory
is completely hidden from the sidebar.
This is because our person has a "user"
role,
which is different to what this directory requires.
Great job! 🎉
Maintaining a consistent style guide is critical for documentation to be easily accessible. This saves time for the person reading it by making it more predictable to navigate.
With this in mind,
it is a good idea to add
automatic linting to our documentation .mdx
files.
In this section, we'll focus on three things:
- adding a script to check if any external URLs are dead or alive.
- linting the
.mdx
files. - checking if relative links in files are valid.
These are extremely useful to maintain consistency and
Maintaining a consistent style guide is crucial for ensuring that documentation is easily accessible. This approach not only enhances readability but also saves time for readers by making the content more predictable and easier to navigate.
To achieve this,
incorporating automatic linting into our documentation .mdx
files is a smart move.
In this section, we will focus on three key tasks:
-
adding a acript to verify external URLs: This script will check if any external links are dead or alive, ensuring that all references are up-to-date and functional.
-
linting the
.mdx
files: Implementing linting tools will help maintain the consistency and quality of our Markdown files by automatically detecting and fixing common formatting pitfalls. -
validating relative links: We will also check if the relative links within the files are valid, preventing broken links and enhancing the user experience.
These steps are instrumental in maintaining a high standard of documentation quality and consistency. Are you ready to get started? Let's go! 🏃♂️
Let's start by installing some dependencies. Run the following commands.
pnpm install -w --saveDev @actions/core contentlayer2 next-contentlayer2 markdown-link-check markdownlint-cli2 markdownlint-rule-relative-links
pnpm install -w next-compose-plugins
next-compose-plugins
makes it easier to add plugins to ourNext.js
application.contentlayer2
converts the content of the site into type-safe JSON data. This will be used to see the contents of the.mdx
files to find URLs, internal or external. It is a maintained fork of the originalcontentlayer
, which maintenance efforts have been halted (see contentlayerdev/contentlayer#651 (comment) for more information).next-contentlayer2
is an adapter ofcontentlayer2
toNext.js
projects.markdown-link-check
extracts links from Markdown texts and checks whether the link is alive (HTTP Code200
) or dead. It also checksmailto:
links. This will be used solely for external and e-mail links.markdownlint-cli2
is the successor of the originalmarkdownlint-cli
, a statis analysis tool to enforce standards and consistency for Markdown files. You can file the linting rules used in https://github.com/DavidAnson/markdownlint#rules--aliases.markdownlint-rule-relative-links
is a custommarkdownlint
rule to validate relative links. It will be used to check for internal/relative links between Markdown files.@actions/core
are a collection of functions for Github Actions. Because we want to employ these steps in our Github Actions, this will be used to mark as a step asfailed
in case linting fails.
Now we're ready to start implementing these features!
Let's start with arguably the "hardest" feature to implement. We're going to scour the Markdown files for external links and then check if they are alive or not.
First, head over to next.config.js
.
We are going to be adding a contentlayer2
plugin
so it can correctly get the site content.
Head over to the file and use withPlugins
from next-compose-plugin
to make it easy to add other plugins.
We are adding the withContentLayer
plugin to our app.
// next.config.js
const withPlugins = require('next-compose-plugins'); // import this
const { withContentlayer } = require('next-contentlayer2'); //import this
const withNextra = require("nextra")({
theme: "./theme/src/index.tsx",
themeConfig: "./theme.config.tsx",
defaultShowCopyCode: true
});
// Use `withPlugins` to compose the plugins and add the `withContentLayer` plugin we've imported
module.exports = withPlugins([withNextra, withContentlayer]);
Next, let's change our tsconfig.json
and add a baseUrl
and a path to path
to it.
This will make it so contentlayer
is accessible in our script.
// tsconfig.json
// ...
"compilerOptions": {
// ...
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"contentlayer/generated": ["./.contentlayer/generated"]
}
}
// ...
Now, we need to define the configuration for contentlayer2
.
We are going to define two documents to match:
- the
.mdx
pages. - the
_meta.json
files.
contentlayer2
needs to be aware of these
so they can properly construct the site contents.
For this, in the root of the project,
create a file called contentlayer.config.js
.
// contentlayer.config.js
import { defineDocumentType, makeSource } from 'contentlayer2/source-files'
const computedFieldsPage = {
slug: {
type: 'string',
resolve: (doc) => doc._raw.sourceFileName.replace(/\.mdx$/, ''),
},
}
export const Page = defineDocumentType(() => ({
name: 'Page',
filePathPattern: `**/*.mdx`,
computedFieldsPage,
}))
export const MetaJson = defineDocumentType(() => ({
name: 'MetaJson',
filePathPattern: `**/_meta.json`,
}))
export default makeSource({
contentDirPath: 'src/pages',
documentTypes: [Page],
onUnknownDocuments: 'skip-ignore'
})
As you can see,
we are using defineDocumentType
to define a type of document expected to find in the docs.
In our case,
we define Page
for .mdx
pages
and MetaJson
for _meta.json
files.
We set the contentDirPath
to src/pages
,
which is the directory where we want contentlayer2
to run.
Now we're ready to create our script!
For this,
create a folder scripts
in the root of the project.
Inside this folder,
add a file called link-check.mjs
.
This will be our script!
import core from "@actions/core";
import markdownLinkCheck from "markdown-link-check";
import { allDocuments } from "../.contentlayer/generated/index.mjs";
// String to tag internal links to be ignored when checking in `markdown-lint-check`
const ignoreURLTag = "#ignoreURL#";
const localhost = "http://localhost:3000";
const baseUrl = process.argv.find((arg) => arg.includes("--baseUrl"));
/**
* This function checks external links that are present in the markdown files.
* It checks if they're alive or dead using `markdown-lint-check`.
* @param {string} markdownAllBody all of the markdowns text
* @returns array of dead links
*/
export async function findDeadExternalLinksInMarkdown(markdownAllBody) {
// Options for `markdown-lint-check`.
// It catches internal links and external links.
const configOpts = {
projectBaseUrl: baseUrl ? baseUrl.replace("--baseUrl=", "https://") : localhost,
replacementPatterns: [
// Match links like `[example](/example) and adding tag to later ignore
{
pattern: "^/",
replacement: `${ignoreURLTag}{{BASEURL}}/`,
},
// Match links like `[example](./example) and adding tag to later ignore
{
pattern: "^./",
replacement: `${ignoreURLTag}{{BASEURL}}/`,
},
],
};
// Runs `markdown-link-check` only on external links.
// If this step fails, check the output. You should find "input". It shows the link that caused the error.
// Most likely the reason it's failing is because the link doesn't start with `./` or `/` (e.g. [example](index.md), instead of [example](./index.md)).
return new Promise((resolve, reject) => {
markdownLinkCheck(markdownAllBody, configOpts, function (error, linkCheckresults) {
// Filtering links for only external URLs
const results = linkCheckresults.map((linkCheckResult) => ({ ...linkCheckResult }));
const filteredResults = results.filter(function (item) {
return !item.link.includes(ignoreURLTag);
});
// Collecting dead links
const deadLinks = filteredResults.filter(result => result.status === "dead").map(result => result.link);
resolve(deadLinks);
});
});
}
// This section only runs when the script is invoked from `package.json`.
// This "if" statement checks if the script is being run from the command line rather than being imported as module - it's the entry point of the script.
if (process.argv[1] === new URL(import.meta.url).pathname) {
(async () => {
// Get the map of all the markdown files after running `contentlayer2`
const allBody = allDocuments.map(({ body }) => body.raw).join("\n--- NEXT PAGE ---\n");
const deadLinks = await findDeadExternalLinksInMarkdown(allBody);
if (deadLinks.length > 0) {
console.error("Dead links found:", deadLinks);
core.setFailed();
} else {
console.log("All links are valid! 🙌");
}
})();
}
Let's go over the changes we've made!
- we create a function
findDeadExternalLinksInMarkdown()
that is responsible for finding all the external links given a markdown text.- we first initialize
configOpts
, where we define the parameters formarkdownLintCheck
,markdown-lint-check
's main function. In this configuration, we define the base URL of the project and, most importantly, the patterns to replace internal links we find. For this, we define a tag (a string) that we'll later use to filter out these internal links, leaving only the external links inside our.mdx
files. - we return a
Promise
which callsmarkdownLintCheck
, which receives the body of all the Markdown files and the configuration we've just defined. Inside the callback function, we filter out the internal links and return an array of dead links.
- we first initialize
- we added an
if
statement that is only invoked when the script is called from the command line. We do this to make it easier to test withJest
. In here, we call the function we've just created (findDeadExternalLinksInMarkdown()
) and provide the text of all the.mdx
files. We get this text aftercontentlayer2
parses through our site. Inside thisif
statement, we callcore.setFailed();
in case anything goes wrong, so the Github Actions workflow fails gracefully.
And that's it!
All that we need to do call this script inside our package.json
!
Simply head over there and,
inside the scripts
section,
add the following line.
"lint:check-external-links": "contentlayer2 build --verbose && node scripts/link-check.mjs"
In here,
we call contentlayer2
to build the content of the .mdx
files
that is later used inside link-check.mjs
.
Important
contentlayer2
generates the data inside a folder called .contentlayer
.
Add it to your .gitignore
so it isn't pushed to version control!
And that's it! All you need to do now is run the script!
pnpm run lint:check-external-links
Depending on the links found in your documentation, the console will log the dead links or if everything is valid!
Important
If you have links in the following format [link](invalid)
,
where the link is internal but does not have /
or ./
in the beginning,
this script will fail because of markdown-lint-check
not being able
to create an URL from invalid
.
You will be able to see the culprit index under the input
property
in the crash log.
TypeError: Invalid URL
at new URL (node:internal/url:797:36)
...
code: 'ERR_INVALID_URL',
input: 'invalid'
Now let's lint our .mdx
files
and check if the relative links are properly set up!
All we need to do is add configuration
to the markdownlint-cli2
package we've installed.
This package will lint the .mdx
files
according to rules we've defined.
We are going to use the default ones,
plus the markdownlint-rule-relative-links
one we've installed.
For this,
in the root of the project,
add a file called .markdownlint-cli2.mjs
.
// .markdownlint-cli2.mjs
// @ts-check
const options = {
config: {
default: true,
"relative-links": true,
MD041: false,
MD013: false,
"no-inline-html": {
// Add React components we want to allow here
allowed_elements: ["LoginOrUserInfo", "Info"],
},
},
globs: ["src/pages/**/*.{md,mdx}"],
ignores: ["**/node_modules", "theme", "scripts"],
customRules: ["markdownlint-rule-relative-links"],
};
export default options;
- we are using the
default
options, with some overrides so it works best with.mdx
files. We've disabledMD041
andMD013
. In addition to this, we only allow specific inline HTML components in our.mdx
files, those beingLoginOrUserInfo
andInfo
components we've defined for the authentication flow. - inside
globs
, we add the paths of the.mdx
files we want to lint. - in
ignores
, we define paths to ignore the linting. - in
customRules
, we add additional, third-party custom rules. In here, we use themarkdownlint-rule-relative-links
we've installed to check the relative internal links.
And that's the hart part done!
Because markdownlint-cli2
uses this config path by default,
all that's left is calling it in our package.json
.
Add the following scripts to it.
"lint:fix-markdown": "markdownlint-cli2 --fix",
"lint:check-markdown": "markdownlint-cli2",
lint:fix-markdown
is meant to be used in development mode. It parses the.mdx
files and attempts to fix any issues.lint:check-markdown
is meant to be used in CI (Github Actions).
And you're done! 🎉 Awesome work!
You can run the script try and fix any issues you may have in your files.
pnpm run lint:fix-markdown
The console will log any issues that arise.
Because we've automated the linting, it's simple for us to use in our Github Actions workflow!
name: Build & Test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
ci:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
# Setup environment (Node and pnpm)
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- uses: pnpm/action-setup@v4
with:
version: latest
# Install dependencies
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
# ADD THESE LINES ----------------------------------------
# Run `markdownlint-cli2` to lint markdown files
- name: Running `markdownlint-cli2`
run: pnpm run lint:check-markdown
# Run script to check for dead links
- name: Checking for dead links
run: pnpm run lint:check-external-links
# ADD THESE LINES ----------------------------------------
# Run build
- name: Build Next.js app
run: pnpm run build
# Run unit tests
- name: Running unit tests
run: pnpm run test
# Run E2E tests
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
- name: Run Playwright tests
run: pnpm exec playwright test
env:
AUTH_SECRET: some_nextauth_secret
TEST_PASSWORD: password
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
As you can see,
we simply call the scripts from our package.json
file!
Simple as! 😃
In Nextra v3
, we cannot use contentlayer2
because it clashes
with Nextra
's dependencies,
making it impossible to use both in the same project.
With this in mind,
we are streamlining the linting process.
We're going to keep using remark
and remark-lint
to lint our .mdx
files.
Run the following command:
pnpm add --save-dev -w yargs ignore url glob remark vfile-reporter to-vfile remark-mdx remark-gfm remark-lint remark-validate-links remark-lint-no-dead-urls
We are going to be using these libraries to create a script
that will use remark
and run through the .mdx
files
and process them.
Let's add a file called .remarkignore
file.
We'll use this file to specify the directories we want to ignore
when linting the .mdx
files.
# `_app.mdx` and `index.mdx` files are essentially `.js` files that don't need linting.
src/**/_app.mdx
src/**/index.mdx
# Parked/archived directories won't be linted
src/**/**/_*
# Linting rules don't need to apply to `README.md`, only for documentation
README.md
Let's create a folder called lint
and create a file called lint.mjs
inside it.
import fs from "fs";
import path from "path";
import yargs from "yargs";
import ignore from "ignore";
import chalk from "chalk";
import { fileURLToPath } from "url";
import { glob } from "glob";
import { remark } from "remark";
import { reporter } from "vfile-reporter";
import { hideBin } from "yargs/helpers";
import {read} from 'to-vfile'
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Counter to keep track of total warnings
let totalWarnings = 0;
/**
* Load and parse the .remarkignore file.
* @param {string} path path to the ignore file.
* @returns {Promise<ignore>} A promise that resolves with the ignore instance.
*/
async function loadIgnoreFile(path) {
try {
const ignoreFileContent = fs.readFileSync(path, "utf-8");
const ig = ignore().add(ignoreFileContent);
return ig;
} catch (err) {
console.warn("No .remarkignore file found, proceeding without ignoring files.");
return ignore();
}
}
/**
* Process a single file with the given preset and formatting flag.
* @param {string} filePath The path to the file
* @param {string} preset The preset object to use for linting with array of remark plugins
* @param {boolean} shouldFormat Flag to indicate whether formatting (changing markdown files) should be applied
*/
async function formatSingleFile(filePath, preset, shouldFormat) {
try {
// Create a virtual file with metadata like `stem`.
// This is needed for rules related to files.
const vfile = await read(filePath);
// Process the file with the given preset
const file = await remark().use(preset).process(vfile);
// Check if there are any issues
const issues = file.messages.length;
// Print the issues
if (issues === 0) {
console.log(`${chalk.green(filePath)}: no issues found`);
} else {
totalWarnings += file.messages.length;
console.error(reporter(file));
}
// Write the file back if formatting is enabled
if (shouldFormat) {
fs.writeFileSync(filePath, file.value.toString());
}
} catch (err) {
console.error(`Error processing file ${filePath}:`, err);
}
}
// Main function to handle glob pattern and process files
/**
* Process files based on the given pattern, preset, and formatting flag.
* @param {string} pattern The glob pattern to match files.
* @param {string} pattern The path to the ignore file.
* @param {string} preset The path to the preset object to use for linting with array of remark plugins.
* @param {boolean} shouldFormat Flag to indicate whether formatting (changing markdown files) should be applied.
* @param {boolean} failOnError Flag to indicate whether to fail command if any error is found.
* @returns {Promise<void>} A promise that resolves when all files are processed.
*/
async function processFiles(pattern, ignoreFile, preset, shouldFormat, failOnError) {
try {
// Load the ignore file and get the list of files
const ig = await loadIgnoreFile(ignoreFile);
const files = await glob(pattern);
if (files.length === 0) {
console.log("No files matched the given pattern.");
return;
}
// Filter out files that are ignored
const filteredFiles = files.filter((file) => !ig.ignores(file));
if (filteredFiles.length === 0) {
console.log("All matched files are ignored.");
return;
}
// Process each file
for (const file of filteredFiles) {
await formatSingleFile(file, preset, shouldFormat);
}
// Print total warnings and fail command if needed
if (totalWarnings > 0) {
console.log(`${chalk.yellow("⚠")} Total ${totalWarnings} warning(s)`);
if (failOnError) {
process.exit(1);
}
}
} catch (err) {
console.error("Error during file processing:", err);
}
}
// Use yargs to handle command-line arguments
const argv = yargs(hideBin(process.argv))
.option("pattern", {
alias: "p",
type: "string",
description: "Glob pattern to match files",
demandOption: true,
})
.option("preset", {
alias: "r",
type: "string",
description: "Path to the preset file",
demandOption: true,
})
.option("ignoreFile", {
alias: "i",
type: "string",
description: "Path to the ignore file",
demandOption: false,
})
.option("format", {
alias: "f",
type: "boolean",
description: "Flag to indicate whether formatting should be applied",
default: false,
})
.option("failOnError", {
alias: "e",
type: "boolean",
description: "Flag to indicate whether to fail command if any error is found",
default: false,
}).argv;
// Dynamically import the preset file
const presetPath = path.resolve(argv.preset);
const preset = await import(presetPath);
// Start processing files
processFiles(argv.pattern, argv.ignoreFile, preset.default, argv.format, argv.failOnError);
Let's break down the script!
-
processFiles
function:- Asynchronously processes files based on a given pattern.
- Loads an ignore file and retrieves a list of files matching the pattern.
- Filters out ignored files.
- Processes each remaining file using a specified preset and formatting option.
- Logs total warnings and exits with an error code if
failOnError
is true.
-
argv
constant:- Uses
yargs
to handle command-line arguments. - Defines options for
pattern
,preset
,ignoreFile
,format
, andfailOnError
. - Parses and stores the command-line arguments.
- Uses
-
presetPath
constant:- Resolves the path to the preset file specified by the user.
-
preset
constant:- Dynamically imports the preset file based on the resolved path.
-
processFiles
invocation:- Calls the processFiles function with the parsed command-line arguments and the imported preset.
With this, this script can be called as a CLI,
which is what we'll do in the package.json
.
We'll provide all the parameters there!
Before calling our script,
we need to create a preset of remark
rules
that we want to apply to our .mdx
files.
In the same lint
folder,
add remark-preset.mjs
and paste the following code.
import remarkMdx from "remark-mdx";
import remarkGfm from "remark-gfm";
import remarkLint from "remark-lint";
import remarkValidateLinks from "remark-validate-links";
// This configuration file is meant to be used in `remark CLI` to check for warnings.
// This means that if any of these fail, the command still succeeds.
// See https://github.com/unifiedjs/unified-engine#options for the options.
const remarkPreset = {
plugins: [
// Support `mdx` and GFM
remarkMdx, // https://mdxjs.com/packages/remark-mdx/
remarkGfm, // https://github.com/remarkjs/remark-gfm
// Introduce remark linting rules
remarkLint,
// Validating URLs
remarkValidateLinks, // https://github.com/remarkjs/remark-validate-links
],
// Override `remark-stringify` rules when serializing text.
// See https://github.com/remarkjs/remark/tree/main/packages/remark-stringify#options for options.
settings: {
emphasis: "_",
strong: "*",
bullet: "-",
},
};
export default remarkPreset;
We're using the default remark-lint
rules,
and adding support to mdx
files (by using remark-mdx
)
and GFM
(by using remark-gfm
).
Links are being validated by remark-validate-links
.
Let's call the script in our package.json
so we can call it in CI or locally.
"scripts": {
"lint:check-markdown": "node './lint/lint.mjs' --pattern 'src/pages/**/*.mdx' --ignoreFile './.remarkignore' --preset './lint/remark-preset.mjs' --failOnError",
"lint:fix-markdown": "node './lint/lint.mjs' --pattern 'src/pages/**/*.mdx' --ignoreFile './.remarkignore' --preset './lint/remark-preset.mjs' --format"
},
As you can see, we are adding the pattern
, ignoreFile
and preset
arguments when calling the script.
Now we are able to run these,
similarly to what we've done in 8.1 Linting in Nextra v2
!
Thanks for learning with us! If you find it useful, please give the repo a star! ⭐️