feat: implement blog using next-mdx-remote

This commit is contained in:
shadcn 2022-10-31 21:52:25 +04:00
parent a82ef82973
commit 4c6d66fa67
15 changed files with 1571 additions and 94 deletions

View file

@ -0,0 +1,42 @@
import { Blog } from "@/lib/mdx/sources"
import { MdxContent } from "@/components/mdx-content"
import { formatDate } from "@/lib/utils"
// TODO: Properly type this file once the following fix lands.
// @see https://github.com/vercel/next.js/pull/42019
interface PostPageProps {
params: {
slug: string[]
}
}
export async function generateStaticParams() {
const files = await Blog.getMdxFiles()
return files?.map((file) => ({
slug: file.slug.split("/"),
}))
}
export default async function PostPage({ params }) {
const post = await Blog.getMdxNode(params?.slug?.join("/"))
return (
<article className="mx-auto max-w-2xl py-12">
<div className="flex flex-col space-y-2">
<h1 className="max-w-[90%] text-4xl font-bold leading-normal">
{post.frontMatter.title}
</h1>
{post.frontMatter.date && (
<p className="text-sm text-slate-600">
{formatDate(post.frontMatter.date)}
</p>
)}
</div>
<hr className="my-6" />
<div className="prose max-w-none">
<MdxContent source={post.mdx} />
</div>
</article>
)
}

View file

@ -0,0 +1,45 @@
import Link from "next/link"
import { Blog } from "@/lib/mdx/sources"
import { formatDate } from "@/lib/utils"
import { Icons } from "@/components/icons"
export default async function BlogPage() {
const posts = await Blog.getAllMdxNodes()
return (
<div className="container mx-auto max-w-3xl px-6 py-12 xl:px-8">
<h1 className="text-3xl font-bold leading-tight sm:text-4xl md:text-5xl">
Blog
</h1>
<p className="mt-4 text-gray-700">
A blog built using MDX content. I copied some sample blog posts from my{" "}
<Link href="https://shadcn.com" target="_blank" className="underline">
personal site
</Link>
.
</p>
<hr className="mt-6 py-6" />
{posts.map((post) => (
<article key={post.slug} className="flex flex-col space-y-4">
<div className="flex flex-col space-y-2">
<Link href={post.url}>
<h2 className="max-w-[90%] text-2xl font-bold leading-normal sm:text-3xl md:text-3xl">
{post.frontMatter.title}
</h2>
</Link>
{post.frontMatter.date && (
<p className="text-sm text-slate-600">
{formatDate(post.frontMatter.date)}
</p>
)}
</div>
{post.frontMatter.excerpt && (
<p className="text-slate-600">{post.frontMatter.excerpt}</p>
)}
<hr className="mt-6 py-6" />
</article>
))}
</div>
)
}

View file

@ -9,10 +9,17 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
return (
<div className="mx-auto w-full px-4">
<header className="mx-auto flex max-w-[1440px] items-center justify-between py-4">
<Link href="/" className="flex items-center space-x-2">
<Icons.logo />
<span className="font-bold">Taxonomy</span>
</Link>
<div className="flex gap-10">
<Link href="/" className="flex items-center space-x-2">
<Icons.logo />
<span className="font-bold">Taxonomy</span>
</Link>
<nav>
<Link href="/blog" className="hover:underline">
Blog
</Link>
</nav>
</div>
<div>
<Link href="/login">Login</Link>
</div>

View file

@ -4,6 +4,10 @@ export default function Head() {
<title>Taxonomy</title>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta
name="description"
content="An open source application built using the new router, server components and everything new in Next.js 13."
/>
</>
)
}

View file

@ -8,7 +8,8 @@ interface RootLayoutProps {
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html>
<html lang="en">
<head />
<body className="min-h-screen">
{children}
<Toaster position="bottom-right" />

View file

@ -0,0 +1,7 @@
"use client"
import { MDXRemote } from "next-mdx-remote"
export function MdxContent({ source }) {
return <MDXRemote {...source} />
}

View file

@ -0,0 +1,64 @@
---
title: Using Postmark with NextAuth.js
excerpt: Send NextAuth emails using Postmark email templates.
date: "2023-03-04"
---
Install Postmark
```bash
yarn add postmark
```
Next add the following environment variables:
```
POSTMARK_API_TOKEN=
SMTP_HOST=smtp.postmarkapp.com
SMTP_PORT=25
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=name <no-reply@name.com>
```
You can find the values for `POSTMARK_API_TOKEN`, `SMTP_USER` and `SMTP_PASSWORD` by visiting Servers → API Tokens from the Postmark dashboard.
To send next-auth emails using Postmark, update the `NextAuth` callback as follows:
```ts {3,5,19-32} title="pages/api/auth/[...nextauth].ts"
import NextAuth from "next-auth"
import Providers from "next-auth/providers"
import { Client } from "postmark"
const postmarkClient = new Client(process.env.POSTMARK_API_TOKEN)
export default NextAuth({
providers: [
Providers.Email({
server: {
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
},
from: process.env.SMTP_FROM,
sendVerificationRequest: async ({ identifier, url, provider }) => {
const result = await postmarkClient.sendEmailWithTemplate({
TemplateId: "TEMPLATE-ID",
To: identifier,
From: provider.from,
TemplateModel: {
// variable: value,
},
})
if (result.ErrorCode) {
throw new Error(result.Message)
}
},
}),
],
})
```

View file

@ -0,0 +1,83 @@
---
title: Single-page Pagination in Next.js
excerpt: Implement pagination with route parameters and rewrites.
date: "2023-04-09"
---
Let's say:
1. We have an API (local or from a headless CMS) returning **150 posts**.
2. We want to display **10 posts per page**.
We want to implement paginated static routes as follows: `/blog/1`, `blog/2` up to `blog/15`.
You can do this in a single page using [route parameters](https://nextjs.org/docs/routing/dynamic-routes) and [rewrites](https://nextjs.org/docs/api-reference/next.config.js/rewrites):
```tsx title="pages/blog/[page].tsx"
interface BlogPageProps {
posts: Post[]
}
export default function BlogPage({ posts }: BlogPageProps) {
// Loop and render posts.
}
export async function getStaticPaths(context): Promise<GetStaticPathsResult> {
// Get total number of posts from API.
const totalPages = await getTotalPagesFromAPI()
const numberOfPages = Math.ceil(totalPages / 10)
// Build paths `blog/0`, `blog/1` ...etc.
const paths = Array(numberOfPages)
.fill(0)
.map((_, page) => ({
params: {
page: `${page + 1}`,
},
}))
return {
paths,
fallback: "false",
}
}
export async function getStaticProps(
context
): Promise<GetStaticPropsResult<BlogPageProps>> {
// Call your API and get the posts for the current page.
const posts = await getPostsFromAPI({
limit: 10,
offset: context.params.page ? 10 * context.params.page : 0,
})
if (!posts.length) {
return {
notFound: true,
}
}
return {
props: {
posts,
},
}
}
```
If we visit `/blog/0` we should see the first 10 posts, `/blog/1` the next 10 posts and so on.
We can use a rewrite to map `/blog` to `/blog/0`.
```js title="next.config.js"
module.exports = {
async rewrites() {
return [
{
source: "/blog",
destination: "/blog/0",
},
]
},
}
```

View file

@ -0,0 +1,40 @@
---
title: Reset Prisma database before running tests with Jest
date: "2023-01-02"
---
Add a custom setup file to your `jest.config.ts`:
```ts title="jest.config.ts"
import type { Config } from "@jest/types"
const config: Config.InitialOptions = {
// ...
setupFilesAfterEnv: ["./src/tests/setup.ts"], // highlight-line
// ...
}
export default config
```
Then in `setup.ts`
```ts title="setup.ts"
import { Prisma } from ".prisma/client"
export async function resetDatabase() {
const tables = Prisma.dmmf.datamodel.models.map((model) => model.dbName)
for (const table of tables) {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${table} CASCADE`)
}
}
beforeEach(async () => {
await resetDatabase()
})
afterAll(async () => {
prisma.$disconnect()
})
```

View file

@ -0,0 +1,93 @@
---
title: Next.js API Routes validation using Middlewares and Yup
date: "2023-01-08"
---
## Middlewares
### withMethods
Let's start with a simple middleware to validate `request.method`.
```ts
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next"
export function withMethods(methods: string[], handler: NextApiHandler) {
return async function (request: NextApiRequest, response: NextApiResponse) {
if (!methods.includes(request.method)) {
return response.status(405).json("")
}
return handler(request, response)
}
}
```
To use this middleware, we call our API handler by wrapping it inside `withMethods`:
```ts
async function handler(request: NextApiRequest, response: NextApiResponse) {}
export default withMethods(["POST"], handler)
```
### withValidation
Next, we're going to create a middleware to validate `request.body`
```ts
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next"
import type { ObjectShape, OptionalObjectSchema } from "yup/lib/object"
export function withValidation<T extends OptionalObjectSchema<ObjectShape>>(
schema: T,
handler: NextApiHandler
) {
return async function (request: NextApiRequest, response: NextApiResponse) {
try {
request.body = await schema.validate(request.body)
return handler(request, response)
} catch (error) {
if (error instanceof yup.ValidationError) {
return response.status(422).json(error.message)
}
return response.status(422).json("")
}
}
}
```
This middleware accepts a schema to validate `request.body` against. We use it as follows:
```ts
const postSchema = yup.object({
title: yup.string().required("`Title` field missing."),
body: yup.string().required("`Body` field missing"),
})
interface Post extends yup.TypeOf<typeof postSchema> {}
async function handler(request: NextApiRequest, response: NextApiResponse) {
const post: Post = request.body
// TODO: Save post.
response.status(201).json("")
}
export default withValidation(postSchema, handler)
```
## Chaining
We can call mutiple middlewares by chaining:
```ts title="pages/api/posts"
export default withMethods(["POST"], withValidation(postSchema, handler))
```
## Code
You can check out the code here: [https://github.com/arshad/nextjs-middleware-validation](https://github.com/arshad/nextjs-middleware-validation).

143
lib/mdx/index.ts Normal file
View file

@ -0,0 +1,143 @@
import { promises as fs } from "fs"
import hasha from "hasha"
import glob from "fast-glob"
import path from "path"
import NodeCache from "node-cache"
import { serialize } from "next-mdx-remote/serialize"
import {
SerializeOptions,
MDXRemoteSerializeResult,
} from "next-mdx-remote/dist/types"
import * as z from "zod"
const mdxCache = new NodeCache()
export interface Source<T> {
contentPath: string
basePath: string
sortBy?: string
sortOrder?: "asc" | "desc"
frontMatter: T
}
interface MdxFile {
filepath: string
slug: string
url: string
}
export interface MdxFileData<TFrontmatter> {
raw: string
hash: string
frontMatter: TFrontmatter
mdx: MDXRemoteSerializeResult
}
export function createSource<T extends z.ZodType>(source: Source<T>) {
const { contentPath, basePath, sortBy, sortOrder } = source
async function getMdxFiles() {
const files = await glob(`${contentPath}/**/*.{md,mdx}`)
if (!files.length) return []
return files.map((filepath) => {
let slug = filepath
.replace(contentPath, "")
.replace(/^\/+/, "")
.replace(new RegExp(path.extname(filepath) + "$"), "")
slug = slug.replace(/\/?index$/, "")
return {
filepath,
slug,
url: `${basePath?.replace(/\/$/, "")}/${slug}`,
}
})
}
async function getFileData(
file: MdxFile,
mdxOptions?: SerializeOptions
): Promise<MdxFileData<z.infer<T>>> {
const raw = await fs.readFile(file.filepath, "utf-8")
const hash = hasha(raw.toString())
const cachedContent = mdxCache.get<MdxFileData<z.infer<T>>>(hash)
if (cachedContent?.hash === hash) {
return cachedContent
}
const mdx = await serialize(raw, {
parseFrontmatter: true,
...mdxOptions,
})
const frontMatter = mdx.frontmatter
? (source.frontMatter.parse(mdx.frontmatter) as z.infer<T>)
: null
const fileData = {
raw,
frontMatter,
hash,
mdx,
}
mdxCache.set(hash, fileData)
return fileData
}
async function getMdxNode(
slug: string | string[],
mdxOptions?: SerializeOptions
) {
const _slug = Array.isArray(slug) ? slug.join("/") : slug
const files = await getMdxFiles()
if (!files?.length) return null
const [file] = files.filter((file) => file.slug === _slug)
if (!file) return null
const data = await getFileData(file, mdxOptions)
return {
...file,
...data,
}
}
async function getAllMdxNodes() {
const files = await getMdxFiles()
if (!files.length) return []
const nodes = await Promise.all(
files.map(async (file) => {
return await getMdxNode(file.slug)
})
)
const adjust = sortOrder === "desc" ? -1 : 1
return nodes.sort((a, b) => {
if (a.frontMatter[sortBy] < b.frontMatter[sortBy]) {
return -1 * adjust
}
if (a.frontMatter[sortBy] > b.frontMatter[sortBy]) {
return 1 * adjust
}
return 0
})
}
return {
getMdxFiles,
getFileData,
getMdxNode,
getAllMdxNodes,
}
}

14
lib/mdx/sources.ts Normal file
View file

@ -0,0 +1,14 @@
import * as z from "zod"
import { createSource } from "."
export const Blog = createSource({
contentPath: "content/blog",
basePath: "/blog",
sortBy: "date",
sortOrder: "desc",
frontMatter: z.object({
title: z.string(),
date: z.string(),
excerpt: z.string().optional(),
}),
})

View file

@ -13,7 +13,6 @@
"lint": "next lint",
"preview": "next build && next start"
},
"packageManager": "yarn@1.22.19",
"dependencies": {
"@editorjs/code": "^2.7.0",
"@editorjs/editorjs": "^2.25.0",
@ -33,9 +32,13 @@
"@radix-ui/react-popover": "1.0.0",
"@radix-ui/react-toggle": "^1.0.0",
"clsx": "^1.2.1",
"fast-glob": "^3.2.12",
"hasha": "^5.2.2",
"lucide-react": "^0.92.0",
"next": "^13.0.0",
"next-auth": "^4.15.0",
"next-mdx-remote": "^4.1.0",
"node-cache": "^5.1.2",
"nodemailer": "^6.8.0",
"postmark": "^3.0.14",
"prop-types": "^15.8.1",

View file

@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
@ -20,15 +16,10 @@
"incremental": true,
"baseUrl": ".",
"paths": {
"@/components/*": [
"components/*"
],
"@/lib/*": [
"lib/*"
],
"@/styles/*": [
"styles/*"
]
"@/components/*": ["components/*"],
"@/lib/*": ["lib/*"],
"@/styles/*": ["styles/*"],
"contentlayer/generated": ["./.contentlayer/generated"]
},
"plugins": [
{
@ -40,9 +31,8 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
".next/types/**/*.ts",
".contentlayer/generated"
],
"exclude": [
"node_modules"
]
"exclude": ["node_modules"]
}

1081
yarn.lock

File diff suppressed because it is too large Load diff