水戸地図(β)

2024年03月23日

Next.js App Router向けのMDXコンテンツマネジメントツールを自作する

概要

Next.js App Routerで使用する簡易なMDXコンテンツマネジメントツールを自作する記事です。RSC(React Server Components)で使うことを前提としています。

追記 (2024/05/25)

この内容を元にnpmでインストール可能なパッケージ Regista を作成しました。

Regista
github.com

使用例

まず先に使用例を提示します。

プロジェクト構造

.
├── 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コンテンツを扱うコンテンツオブジェクトpostdefineMdx関数で、著者情報を扱うコンテンツオブジェクトauthordefineData関数で定義し、エクスポートします。

// 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内で先に定義したpostauthorをインポートして使用します。

// 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として扱う

サンプルプロジェクト

サンプルプロジェクト
nextjs-mdx-content-management-example.vercel.app

環境

使用ライブラリ

  • next-mdx-remote v4.4.1
  • Zod v3.22.4
  • yaml v2.4.1
Zod
zod.dev

作成例

それでは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スキーマはparsesafeParseの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の コンテンツコレクション に着想を得ています。大規模なデータを扱うには不向きかもしれませんが、小規模なサイトの構築には十分機能すると思います。改良点を挙げればキリがありませんが、とりあえず一つの作例として提示しておきます。

サンプルプロジェクト
nextjs-mdx-content-management-example.vercel.app
Zod
zod.dev

Lambert: D’un feu secret / Cécile McLorin Salvant (2023)

広告

2024年03月23日 最終更新日2024年05月25日

Next.js App Router向けのMDXコンテンツマネジメントツールを自作する

技術記事

Top

水戸地図(β)