2024年03月23日
Next.js App Router向けのMDXコンテンツマネジメントツールを自作する
概要
Next.js App Routerで使用する簡易なMDXコンテンツマネジメントツールを自作する記事です。RSC(React Server Components)で使うことを前提としています。
追記 (2024/05/25)
この内容を元にnpmでインストール可能なパッケージ Regista を作成しました。
使用例
まず先に使用例を提示します。
プロジェクト構造
.
├── content
│ ├── author // 著者情報のYAMLファイルの置き場所
│ └── post // Markdown/MDXファイルの置き場所
├── public
│ └── assets
├── src
│ ├── app
│ ├── components
│ ├── content
│ └── mdx-components.tsx
├── next.config.mjs
├── package.json
└── tsconfig.json
src/content/index.ts
内でMarkdown/MDXコンテンツを扱うコンテンツオブジェクトpost
をdefineMdx
関数で、著者情報を扱うコンテンツオブジェクトauthor
をdefineData
関数で定義し、エクスポートします。
// src/content/index.ts
import { z } from "zod";
export const post = defineMdx({
contentPath: path.resolve(process.cwd(), "content/post"),
basePath: "/post",
schema: {
author: z.string().optional(),
},
});
export type PostFrontmatter = z.infer<typeof post.schema>;
export type PostMetadata = z.infer<typeof post.metadataSchema>;
export const author = defineData({
contentPath: path.resolve(process.cwd(), "content/author"),
schema: {
name: z.string(),
description: z.string().optional(),
},
});
export type Author = z.infer<typeof author.schema>;
コンテンツの定義が完了したらsrc/app/post/[…slug].tsx
内で先に定義したpost
とauthor
をインポートして使用します。
// src/app/post/[...slug].tsx
import NextLink from "next";
import { post, author } from "@/content";
export async function generateStaticParams() {
const allPosts = await post.getAll();
return allPosts;
}
export default async function Page({ params }: { params: { slug: string[] } }) {
const { slug } = params;
const item = await post.useMdx(slug);
if (!item) return null;
const { content, frontmatter } = item;
const { title, date } = frontmatter;
const authorData = await author.get("name", frontmatter.author);
return (
<article>
<header>
<h1>{title}</h1>
<time>{date}</time>
</header>
{content}
<footer>
<p>{title}</p>
<time>{date}</time>
{authorData && (
<address>
<p>{authorData.name}</p>
{authorData.description && <small>{authorData.description}</small>}
</address>
)}
</footer>
</article>
);
}
この記事の主旨は上記のような使い方ができるコンテンツマネジメントツールのdefineMdx
関数とdefineData
関数を作成することです。
設計
Astroの コンテンツコレクション に着想を得て以下のような設計にしています。
- コンテンツやデータを
src
以外のディレクトリに置く - コンテンツやデータを
src/content/index.ts
で定義しエクスポートする - コンテンツ定義ではMarkdown/MDXのフロントマターやデータの型をZodスキーマで定義する
- コンテンツオブジェクトは全てのコンテンツのメタデータを取得する
await getAll()
と特定のコンテンツのメタデータを取得するawait get()
をメソッドに持つ await getAll()
とawait get()
の返り値はZodで定義した型を基に型安全にする- Markdown/MDXのコンテンツでは隣接する記事の情報を
context
として扱う
サンプルプロジェクト
環境
使用ライブラリ
- next-mdx-remote v4.4.1
- Zod v3.22.4
- yaml v2.4.1
作成例
それではdefineMdx
関数とdefineData
関数の作成例を見ていきます。
MDXコンテンツを定義するdefineMdx
関数
指定したディレクトリ内のMarkdown/MDXファイルを読み込みコンテンツとして定義するdefineMdx
関数の作成例です。
defineMdx
関数で定義したコンテンツオブジェクトは以下のようなメソッド・プロパティを持ちます。
await getAll()
: すべてのMarkdown/MDXコンテンツのメタデータを返すawait get()
: 特定のMarkdown/MDXコンテンツのメタデータを返すawait useMdx()
: 特定のMarkdown/MDXコンテンツを変換したReactコンポーネントとフロントマターを返すschema
: Markdown/MDXコンテンツのフロントマターのZodスキーマを返すmetadataSchema
: Markdown/MDXコンテンツのメタデータのZodスキーマを返す
やや冗長な部分があったり、型パズルが解けずに型アサーション(as
)を使っている部分があるのは不本意ですが、あくまで作成例の一つとしてご覧ください。
// src/content/defineMdx.ts
import * as path from "path";
import { readdir, readFile } from "fs/promises";
import { compileMDX, type MDXRemoteProps } from "next-mdx-remote/rsc";
import { z, type ZodObject, type ZodRawShape } from "zod";
import { fileNameToSlug, dataSchemaVaridator } from "./utils";
const defaultFrontmatterSchema = z.object({
title: z.string(),
date: z.coerce.date(),
lastmod: z.coerce.date(),
draft: z.boolean(),
});
const defaultFrontmatterSchemaInput = defaultFrontmatterSchema.partial({
lastmod: true,
draft: true,
});
export type Frontmatter<
T extends Record<string, any> = Record<string, unknown>,
> = T & z.infer<typeof defaultFrontmatterSchema>;
export type FrontmatterInput<
T extends Record<string, any> = Record<string, unknown>,
> = T & z.infer<typeof defaultFrontmatterSchemaInput>;
export type Metadata<
TFrontmatter extends Record<string, any> = Record<string, unknown>,
> = {
frontmatter: TFrontmatter;
absolutePath: string;
slug: string[];
href: string;
};
function complementFrontmatter<T extends Record<string, any>>({
title,
date,
lastmod,
draft,
...rest
}: FrontmatterInput<T>): Frontmatter<T> {
return {
title,
date: new Date(date),
lastmod: lastmod ? new Date(lastmod) : new Date(date),
draft: typeof draft === "boolean" ? draft : false,
...rest,
} as Frontmatter<T>;
}
export function defineMdx<Z extends ZodRawShape>({
contentPath,
basePath,
schema,
extensions = ["md", "mdx"],
}: {
contentPath: string;
basePath: string;
schema: Z;
extensions?: string[];
}) {
type RestFrontmatter = z.infer<ZodObject<Z>>;
const frontmatterSchema = defaultFrontmatterSchema
.extend(restFrontmatter)
.passthrough();
const metadataSchema = z.object({
frontmatter: frontmatterSchema,
absolutePath: z.string(),
slug: z.array(z.string()),
href: z.string(),
});
const varidator = dataSchemaVaridator(frontmatterSchema);
async function getAll(): Promise<
(Metadata<Frontmatter<RestFrontmatter>> & {
context: {
older: Metadata<Frontmatter<RestFrontmatter>> | null;
newer: Metadata<Frontmatter<RestFrontmatter>> | null;
};
})[]
> {
const filesInDir = await readdir(contentPath, {
encoding: "utf8",
recursive: true,
});
const files = filesInDir.filter((fileName) =>
extensions.some((ext) => new RegExp(`.${ext}$`).test(fileName)),
);
const allPosts: Metadata<Frontmatter<RestFrontmatter>>[] = (
await Promise.all(
files.map(async (filename) => {
const absolutePath = path.join(contentPath, filename);
const source = await readFile(absolutePath, { encoding: "utf8" });
const { frontmatter } = await compileMDX<
FrontmatterInput<RestFrontmatter>
>({
source,
options: { parseFrontmatter: true },
});
return {
data: complementFrontmatter(frontmatter),
absolutePath,
filename,
};
}),
)
)
.filter(varidator)
.map(({ data, absolutePath, filename }) => {
const slug = fileNameToSlug(filename);
const href = path.join(basePath, ...slug);
return {
frontmatter: data,
absolutePath,
slug,
href,
};
});
return allPosts
.filter(
({ frontmatter }) =>
process.env.NODE_ENV === "development" || !frontmatter.draft,
)
.sort(
(a, b) =>
a.frontmatter.date.getTime() - b.frontmatter.date.getTime() ||
a.frontmatter.lastmod.getTime() - b.frontmatter.lastmod.getTime(),
)
.map((post, index, arr) => ({
...post,
context: {
older: arr[index - 1] ?? null,
newer: arr[index + 1] ?? null,
},
}));
}
async function get(slug: string[]) {
const alls = await getAll();
const datum = alls.find((post) => post.slug.join("/") === slug.join("/"));
return datum;
}
async function useMdx(
slug: string[],
{ components, options }: Omit<MDXRemoteProps, "source"> = {},
) {
const datum = await get(slug);
if (!datum) return null;
const { absolutePath, context, frontmatter } = datum;
const file = await readFile(absolutePath, { encoding: "utf8" });
const { content } = await compileMDX({
source: file,
components,
options: {
...options,
parseFrontmatter: true,
},
});
return {
content,
context,
frontmatter,
};
}
return {
schema: frontmatterSchema,
metadataSchema,
get,
getAll,
useMdx,
};
}
データを定義するdefineData
関数
指定したディレクトリ内のYAMLファイルやJSONファイルを読み込みデータとして定義するdefineData
関数の作成例です。
defineData
関数で定義したコンテンツオブジェクトは以下のようなメソッド・プロパティを持ちます。
await getAll()
: すべてのデータ(メタデータ込み)を返すawait get()
: 特定のデータ(メタデータ込み)を返すschema
: データのZodスキーマを返す
// src/content/defineData.ts
import { readFile, readdir } from "fs/promises";
import * as path from "path";
import { z, type ZodRawShape } from "zod";
import { dataSchemaVaridator, dataFormatter, type DataFormat } from "./utils";
export function defineData<T extends ZodRawShape>({
contentPath,
schema,
format = "yaml",
}: {
contentPath: string;
schema: T;
format?: DataFormat;
}) {
const { extensions, parser } = dataFormatter(format);
const dataSchema = z.object({ id: z.string() }).extend(schema).passthrough();
const varidator = dataSchemaVaridator(dataSchema);
async function getAll(): Promise<z.infer<typeof dataSchema>[]> {
const filesInDir = await readdir(contentPath, {
encoding: "utf8",
recursive: true,
});
const files = filesInDir.filter((fileName) =>
extensions.some((ext) => new RegExp(`.${ext}$`).test(fileName)),
);
const collection = (
await Promise.all(
files.map(async (filename) => {
const absolutePath = path.join(contentPath, filename);
const file = await readFile(absolutePath, "utf8");
const datum = parser(file);
return {
data: { id: filename.replace(/\.[^/.]+$/, ""), ...datum },
filename,
};
}),
)
)
.filter(varidator)
.map(({ data }) => data);
return collection;
}
async function get(
key: keyof z.infer<typeof dataSchema>,
value: unknown,
): Promise<z.infer<typeof dataSchema> | undefined> {
const data = await getAll();
return data.find((datum) => datum?.[key] === value);
}
return {
schema: dataSchema,
get,
getAll,
};
}
ユーティリティ
// src/content/utils.ts
import { z, type ZodType } from "zod";
import * as yaml from "yaml";
/**
* example:
* getting-started.mdx => ["getting-started"]
* 2024/hoge.mdx => ["2024", "hoge"]
* 2024/nested-post/index.md => ["2024", "nested-post"]
*/
export function fileNameToSlug(filename: string) {
const indexPattern = /\/index.(md|mdx)$/;
const pattern = /.(md|mdx)$/;
return filename.replace(indexPattern, ".mdx").replace(pattern, "").split("/");
}
export function schemaVaridator<T extends ZodType>(schema: T) {
return (data: unknown): data is z.infer<typeof schema> => {
const result = schema.safeParse(data);
if (!result.success) {
console.error(result.error.message);
}
return result.success;
};
}
export function dataSchemaVaridator<T extends ZodType>(schema: T) {
return (input: {
data: unknown;
filename: string;
}): input is { data: z.infer<typeof schema>; filename: string } => {
const { data, filename } = input;
const result = schema.safeParse(data);
if (!result.success) {
console.error(filename, result.error.message);
}
return result.success;
};
}
export const dataFormat = z.enum(["yaml", "json"]);
export type DataFormat = z.infer<typeof dataFormat>;
export function dataformatToExts(format: DataFormat) {
if (format === "yaml") return ["yml", "yaml"];
return ["json"];
}
export function parseData(format: DataFormat) {
if (format === "yaml") return (raw: string) => yaml.parse(raw);
return (raw: string) => JSON.parse(raw);
}
export function dataFormatter(format: DataFormat) {
const extensions = dataformatToExts(format);
const parser = parseData(format);
return { extensions, parser };
}
Tips
Markdown/MDXコンテンツを検索し、ファイルパスからSlugを作成する
Next.js App RouterのダイナミックルーティングはgenerateStaticParams
関数をエクスポートすることでパラメータからルートを作成することができます。このときファイル名を[…slug].tsx
とすることでスラッシュで区切られた階層型のルートを作成することができます。
// src/app/post/[...slug].tsx
import { readdir, readFile } from "fs/promises";
import * as path from "path";
async function getAllMdxFiles(contentPath: string) {
const filesInDir = await readdir(contentPath, {
encoding: "utf8",
recursive: true,
});
const files = filesInDir.filter((fileName) =>
[".md", ".mdx"].some((ext) => new RegExp(`${ext}$`).test(fileName)),
);
return files.map((fileName) => ({
slug: fileNameToSlug(fileName), // => string[]
}));
}
export async function generateStaticParams() {
const contentPath = path.resolve(process.cwd(), "content/post");
const allMdxFiles = await getAllMdxFiles(contentPath);
return allMdxFiles;
}
export default async function Page({
params,
}: {
params: { slug: string[] },
}) {
const { slug } = params;
// ...
}
上の例ではgenerateStaticParams
関数でエクスポートするslug
の型はstring[]
となるため、ファイルパスをSlugに変換する作業が必要になります。ここでは/hoge/index.mdx
のパターンをhoge.mdx
と同様の扱いにするため、ここではString.replace
を2回実行する力技を用いています。
/**
* example:
* getting-started.mdx => ["getting-started"]
* 2024/hoge.mdx => ["2024", "hoge"]
* 2024/nested-post/index.md => ["2024", "nested-post"]
*/
function fileNameToSlug(filename: string) {
const indexPattern = /\/index.(md|mdx)$/;
const pattern = /.(md|mdx)$/;
return filename.replace(indexPattern, ".mdx").replace(pattern, "").split("/");
}
SlugからMarkdown/MDXファイルを取得する
Next.js App Routerのダイナミックルーティングの場合、デフォルトエクスポートを行うページの関数ではルーティングに用いた変数のみがパラメータとしてPropsに渡されます。上の例ではslug
がパラメータとして渡されるため、ページの関数内ではslug
からMarkdown/MDXファイルを取得するフックが必要となります。
export default async function Page({ params }: { params: { slug: string[] } }) {
const { slug } = params;
const item = await useMdx(slug);
if (!item) return null;
const { content, frontmatter, context } = item;
const { title, date } = frontmatter;
const { older, newer } = context;
// ...
}
開発時には下書き記事を表示、本番環境では非表示
async function getAll() {
return allPosts
.filter(({ draft }) => process.env.NODE_ENV === "development" || !draft);
}
Zodでスキーマを定義する
Zodはスキーマの定義、検証のためのライブラリです。Zodでは以下の例のようにスキーマを定義することができます。定義したスキーマはz.infer
でTypeScriptの型定義に変換することができます。
import { z } from "zod";
const schema = z.object({
id: z.string(),
name: z.string(),
age: z.number().optional(),
});
type Schema = z.infer<typeof schema>;
// => { id: string; name: string; age?: number | undefined }
const extended = schema.extend({
socials?: z.object({
twitter: z.string().optional(),
github: z.string().optional(),
}).optional(),
});
type ExtendedSchema = z.infer<typeof extended>;
// => { id: string; name: string; age?: number; socials?: { twitter?: string | undefined; github?: string | undefined; } | undefined }
defineMdx
関数やdefineData
関数ではユーザがZodでスキーマを定義します。これにより、await getAll()
、await get()
メソッドはユーザが定義したZodスキーマを基に、型安全なデータを返します。
Zodスキーマは拡張・変形が可能です。defineMdx
関数ではフロントマターに必須となるフィールド(title
, date
)とインプットでは必須ではないもののアウトプットでは必ず存在するフィールド(lastmod
, draft
)をユーザーが定義した型と結合しています。
defineData
関数ではid
フィールドをファイル名から生成し、ユーザーが定義する型と結合しています。
Zodでデータの型チェックを行う
Zodの重要な機能にデータの検証があります。Zodスキーマはparse
とsafeParse
の2種類のパーサをメソッドに持ちます。parse
はデータの型チェックが成功した場合はそのままデータを返し、失敗した場合はエラーを投げます。一方safeParse
は型チェックの失敗成功に関わらず返り値がオブジェクトであるため、例外処理が扱いやすいメリットがあります。
この記事では単純な型チェックだけを扱いますが、Zodのスキーマ検証には文字列の解析や数値の最大値、最小値の設定など多くの機能が備わっています。
import { z } from "zod";
const schema = z.object({
id: z.string(),
name: z.string(),
age: z.number().optional(),
});
const foo = { id: "foo", name: "Foo" };
const bar = { id: "bar", name: 20, age: 20 };
schema.parse(foo); // => { id: "foo", name: "Foo" }
schema.parse(bar); // => throws error
schema.safeParse(foo);
// => { success: true, data: { id: "foo", name: "Foo" } }
schema.safeParse(bar);
// => { success: false; error: ZodError }
以下のコードはZodスキーマのsafeParse
を使ってany
型の配列データを型安全な配列に変換・フィルタリングするvaridator関数を作成する例です。
import { readdir, readFile } from "fs/promises";
import * as path from "path";
import { z, type ZodType } from "zod";
function schemaVaridator<T extends ZodType>(schema: T) {
return (data: unknown): data is z.infer<typeof schema> => {
const result = schema.safeParse(data);
if (!result.success) {
console.error(result.error.message);
}
return result.success;
};
}
const mySchema = z.object({
id: z.string(),
name: z.string(),
age: z.number().optional(),
});
const mySchemaVaridator = schemaVaridator(mySchema);
async function readFiles(contentPath: string) {
const files = await readdir(contentPath, {
encoding: "utf8",
recursive: true,
});
const raw = await Promise.all(
files.map(async (fileName) => {
const absolutePath = path.join(contentPath, fileName);
const file = await readFile(absolutePath, "utf8");
return JSON.parse(file);
})
);
// => any[]
const data = raw.filter(mySchemaVaridator);
return data;
// => { id: string; name: string; age?: number | undefined }[]
}
next-remote-mdxでMarkdown/MDXファイルを読み込む
next-remote-mdxではRSC(React Server Components)に対応したnext-remote-mdx/rsx
APIが利用できます。ただしnext-remote-mdx/rsc
APIはv4.4.1の時点ではUnstableであるため使用には注意が必要です。
next-remote-mdxは<MDXRemote>
コンポーネントを使う方法が一般的ですが、defineMdx
関数ではフロントマターをレンダリングの外部で扱うためcompileMDX
関数を用いています。
import { z } from "zod";
import { compileMDX } from "next-mdx-remote/rsc";
const frontmatterSchema = z.object({
title: z.string(),
date: z.coerce.date(),
});
type FrontmatterSchema = z.infer<typeof frontmatterSchema>;
async function useMdx(absolutePath: string) {
const source = await readFile(absolutePath, "utf8");
const { content, frontmatter } = await compileMDX<FrontmatterSchema>({
source,
options: { parseFrontmatter: true },
});
return { content, frontmatter };
}
compileMDX
ではジェネリックスを用いてフロントマターの型を指定することができますが、読み込むMarkdown/MDXファイルのフロントマターが望んだ型と一致するとは限らないため、前述のvaridator関数を使って一致しないものを取り除く作業をするといいでしょう。
まとめ
Next.js App RouterでMarkdown/MDXを単体のページではなくコンテンツソースとして扱う方法が知りたかったのですが情報があまり見つかりませんでした。主要ライブラリの Contentlayer は 半年以上メンテナンスが止まっており、Next.js v14をサポートしていません 。
そのような動機でNext.js App Router向けの簡易なコンテンツマネジメントツールを自作してみました。Zodを使う方法はAstroの コンテンツコレクション に着想を得ています。大規模なデータを扱うには不向きかもしれませんが、小規模なサイトの構築には十分機能すると思います。改良点を挙げればキリがありませんが、とりあえず一つの作例として提示しておきます。
Lambert: D’un feu secret / Cécile McLorin Salvant (2023)
Next.js
Next.jsはWebサイト/アプリケーションを構築するReactベースのフレームワークです。
https://nextjs.org/