Next.js App RouterとMDXでリッチなスキルシートをサクッと書く

ラクスパートナーズのWebエンジニアの安井です。
普段は主にWebフロントエンドの開発を行なっています。

この記事は [ラクスパートナーズ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/rakus-partners) の4日目の記事になります。
[ラクス Advent Calendar 2023](https://qiita.com/advent-calendar/2023/rakus) の20日目にも参加予定なのでそちらもよろしくお願いいたします。

## この記事を書いた背景

弊社ラクスパートナーズはラクスの子会社でITエンジニアの派遣サービスを行なっている会社です。
我々エンジニアは日頃クライアントである派遣先企業で業務を行っていますが、派遣先企業に参画する前段階でクライアントへの提案資料として自身のスキルや経歴を記載したスキルシートを作成します。

このスキルシートはエクセルで用意された雛形に内容を記載していく形で作成しています。
ただエクセルでの文書コンテンツの編集体験はあまり良いとは言えず、エクセルでの作成、管理が果たして本当にベストなのか？と疑問に思ったことが私は何度かあります。
私含め同じように感じたエンジニアが過去にもいたようで、幾度か別のフォーマットや管理ツールへ移行できないか画策されたことがあるそうです。
(私も同期のエンジニアと共同して管理ツールの開発にチャレンジしましたが、エクセルの柔軟性を表現するアイディアが当時は浮かばず断念しました。)

近頃またスキルシートのフォーマットに関して検討の動きがあったようなので、自分としてもいろいろ模索してみようと思いこの記事の執筆に至りました。

## 今回のアプローチ
記事のタイトルにもありますが今回はスキルシートの記述フォーマットとして[MDX](https://mdxjs.com/)を利用します。

MDXについての説明は割愛しますが、markdownの中でjsxの利用を可能にした技術です。
markdownの中でReactコンポーネントが利用できるため、markdown単体で利用するより表現力や柔軟性のあるコンテンツの作成が可能になります。

スキルシートの記述にあたり、markdownを利用することで一定の制約と記述のしやすさは得られます。
一方でmarkdownだけだと表現力に乏しい点はネックになるため、必要な範囲で拡張UIをReactコンポーネントで用意することでリッチなスキルシートを作成しやすいのではないかと考えました。

Reactコンポーネントベースで表現力を拡張できる点も、組織内で利用するフォーマットを一定揃えていったり、パーツ単位で再利用できるためスキルシート作成にかかる工数の削減を図ることもできるかもしれないと考えています。

MDXを利用する方法はいくつかあります。
この記事の執筆にあたり、最初はViteでの利用を試していましたが、Next.jsだとNext.js側の公式ドキュメントに利用のガイドがあり、設定もViteと比較して少なく利用できたのでNext.jsを利用することにしました。

Next.jsのRouterについてはAppRouter/Page Routerのどちらでも今回やりたいことは実現できそうでしたが、以前Page RouterでMDXを利用したことがあったので今回はApp Routerを利用してみる形としました。

## バージョン情報
- Next.js v14.0.3
- Node.js v20.10.0
- @mdx-js/react v3.0.0
- @mdx-js/loader v3.0.0
- tailwindcss v3.3.5

今回のサンプルコードは下記のGitHubリポジトリ参照。
https://github.com/yuichiyasui/mdx-profile/tree/1.0.0

## プロジェクト作成
まずはNext.jsのプロジェクトを作成します。
TailwindCSSを利用するのでプロジェクト作成の段階で選択しておくと設定が完了した状態でプロジェクトが作成されるので楽です。
https://nextjs.org/docs/getting-started/installation

## Next.jsでMDXを利用できるようにする
続いてNext.jsでMDXを利用できるようにします。
こちらも公式ドキュメント通りで進めることができます。
https://nextjs.org/docs/app/building-your-application/configuring/mdx

注意点としてドキュメントにも書いていますが`mdx-components.tsx`はプロジェクトルート直下に作成します。
```
.
├── README.md
├── mdx-components.tsx
├── next.config.js
├── package.json
├── pnpm-lock.yaml
├── public
├── src
│   ├── app
└── tsconfig.json
```

## マークダウンに対してスタイルをつける
マークダウンに対応するスタイルを定義します。
`mdx-components.tsx`に定義していきます。

```tsx:mdx-components.tsx
import type { MDXComponents } from "mdx/types";

export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    ...components,
    h1: (props) => (
      <h1 className="py-2 text-3xl font-bold">{props.children}</h1>
    ),
    h2: (props) => (
      <h2 className="mb-4 border-b border-b-gray-200 pb-1 pt-2 text-2xl font-bold">
        {props.children}
      </h2>
    ),
    h3: (props) => (
      <h3 className="mb-2 py-1 text-xl font-bold">{props.children}</h3>
    ),
    h4: (props) => <h4 className="py-1 text-lg font-bold">{props.children}</h4>,
    table: (props) => (
      <table className="w-full border-collapse">{props.children}</table>
    ),
    th: (props) => (
      <th className="whitespace-nowrap border border-gray-200 bg-gray-100 px-4 py-2">
        {props.children}
      </th>
    ),
    td: (props) => (
      <td className="border border-gray-200 px-4 py-2">{props.children}</td>
    ),
    ul: (props) => (
      <ul
        className="list-inside list-disc">
        {props.children}
      </ul>
    ),
    li: (props) => <li className="pl-2">{props.children}</li>,
    a: (props) => (
      <a
        className="text-blue-600 hover:underline"
        target="_blank"
        rel="noopener noreferrer"
        {...props}
      >
        {props.children}
      </a>
    ),
  };
}
```

TailwindCSSのクラスが生成されるように`tailwind.config.ts`のcontentプロパティに`mdx-components.tsx`を追加しておきます。

```ts:tailwind.config.ts
import type { Config } from "tailwindcss";

const config: Config = {
  content: [
    "./mdx-components.tsx",
    "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
```

現状だとtableなどの一部のmarkdown記法に対してはHTML要素に変換されていない状態になっています。

MDXのドキュメントによるとtable等の一部の要素については [GFM (GitHub flavored markdown)](https://github.com/remarkjs/remark-gfm)を導入することで利用可能になるとのことです。
https://mdxjs.com/table-of-components/
https://mdxjs.com/guides/gfm/

Next.jsでのGFMの利用についてもNext.jsの公式ドキュメントに記載があるのでこちらを参考にインストールを行います。
https://nextjs.org/docs/app/building-your-application/configuring/mdx#remark-and-rehype-plugins

注意点としてこちらもドキュメントに記載がありますが、`remark-gfm`はESModulesのみで提供されているので`next.config.js`を`next.config.mjs`に変更して、ESModules形式に書き換える必要があります。

```js:next.config.mjs
import createMDX from "@next/mdx";
import remarkGfm from "remark-gfm";

/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ["js", "jsx", "mdx", "ts", "tsx"],
};

const withMDX = createMDX({
  options: {
    remarkPlugins: [remarkGfm],
  },
});

export default withMDX(nextConfig);
```

ここまででMDXが利用可能になりました。
`src/app`ディレクトリ配下の任意のパスで`page.mdx`を作成して動作確認を行います。

:::note warn
今回利用したNext.jsのバージョンではTurboPackを使うとmdxが動きませんでした。
```json:package.json
  "scripts": {
    "dev": "next dev --turbo",
```

```
Error: not an ecmascript module
```
:::

:::note warn
remark-gfmを利用している場合にNext.jsのexperimental機能である[mdxRs](https://nextjs.org/docs/app/building-your-application/configuring/mdx#using-the-rust-based-mdx-compiler-experimental)を有効にした場合下記のようなエラーが発生しました。

```js:next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  pageExtensions: ["js", "jsx", "mdx", "ts", "tsx"],
  experimental: {
    mdxRs: true
  }
};
```

```
Error: page.mdx:Error: "16:60: Could not parse expression with swc: Unexpected eof"
```
:::

## MDXでスキルシートを書く
`page.mdx`にスキルシートを記述していきます。

:::note info
今回作成したスキルシートのフォーマットおよび記載内容はあくまで一般的なフォーマットを模倣したもので弊社で利用している雛形や私の経歴とは一切関係ありません。
:::

```mdx:page.mdx
import { CreatedAt } from "./_components/CreatedAt";
import { DescriptionList } from "./_components/DescriptionList";
import { Spacer } from "./_components/Spacer";
import { PageBreak } from "./_components/PageBreak";

import JobMdx from "./job.mdx";

# スキルシート

<CreatedAt date="2023/12/4" />

## 基本情報

<DescriptionList
  contents={[
    { title: "会社名", description: "株式会社○○" },
    { title: "氏名", description: "サンプル 太郎" },
    { title: "経験年数", description: "○年○ヶ月" },
  ]}
/>

<Spacer />

## スキル概要

<DescriptionList
  contents={[
    {
      title: "言語",
      description: ["HTML", "CSS", "JavaScript", "Java", "Go"],
    },
    {
      title: "フレームワーク",
      description: [
        "Next.js",
        "Apollo",
        "Express",
        "NestJS",
        "SpringBoot",
        "Gin",
      ],
    },
    {
      title: "開発ツール",
      description: ["VSCode", "GitHub"],
    },
  ]}
/>

<Spacer />

## 資格

- 基本情報技術者試験
- AWS SAA

<Spacer />

## 自己PR

技術トレンドや開発プラクティスなどを積極的に追っています。

プロダクトのUI/UXの向上、開発環境の快適化、コードのメンテナンス性の向上を追い求めて仕入れた情報で使えそうなものがあれば積極的に取り入れる提案をする姿勢を持っています。

<Spacer />
<PageBreak />

## 職務経歴

### ○○プロジェクト

<DescriptionList
  contents={[
    {
      title: "期間",
      description: "2020/1/1 ~ 2020/12/31",
    },
    {
      title: "概要",
      description: `
      ○○の開発
      ○○の保守運用
      `,
    },
    {
      title: "役割",
      description: ["フロントエンドエンジニア", "スクラムマスター"],
    },
    {
      title: "担当",
      description: "○○の開発",
    },
    {
      title: "利用技術",
      description: "○○",
    },
    {
      title: "業務内容",
      description: <JobMdx />,
    },
  ]}
/>

```

```mdx:job.mdx
import { Spacer } from "./_components/Spacer";

**開発内容**

- APIの開発
- データベースの設計
- フロントエンドの開発

<Spacer />
<img width="300" height="200" src="sample.png" alt="" />
<Spacer />

**苦労したこと**

- 障害対応

<Spacer />

**身についたこと**

- 障害対応力
- フロントエンド開発力
- データベース設計力

<Spacer />

```

<details><summary>PDF印刷結果</summary>

１ページ目
![スクリーンショット 2023-12-03 23.43.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549769/c315d1cf-c140-8bc1-e3be-4aabd7004697.png)

2ページ目
![スクリーンショット 2023-12-03 23.43.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549769/fde71b3e-9336-2292-35d8-e22126447118.png)

</details>

今回コンポーネントとしては下記の4種類を作成しました。
- CreatedAt: 作成日の表示
- DescriptionList: 見出し/内容のリスト
- PageBreak: 改ページ用
- Spacer: 余白を取る用

MDX内でもTailwindCSSが利用できるのでDescriptionList以外のコンポーネントはコンポーネント化しないという選択もできそうでしたが、可読性と利用のしやすさを考慮してコンポーネント化しました。

一番最後の業務内容の部分だけ別のMDXに切り出して記述を行っています。
これはReactコンポーネントのPropsでコンテンツ内容を渡す場合に、親からmarkdownで記述した文字列を渡してもReactコンポーネント側でプレーンな文字列として解釈され、HTML要素に変換されないためMDXで渡すことで回避しています。
このあたりは[react-markdown](https://github.com/remarkjs/react-markdown)を利用すれば、文字列として渡したmarkdownをReactコンポーネント側でparseしてHTML要素に変換することもできそうです。

PDFへの出力については現状ではローカルでサーバーを起動してブラウザで印刷を実行してPDFとして出力する形としています。

ただしそのままの設定だと印刷時に背景色が表示されません。
下記のCSSのプロパティを設定することで背景色が印刷されるようになります。

```css:src/styles/globals.css
body {
  -webkit-print-color-adjust: exact;
}
```

## 感想
コンポーネントを作成してしまえばかなり簡単に記述ができたので体験としては非常に良いと感じました。
ただ一方でmdxだとmarkdownの補完が効かなかったりしたので、VSCodeの拡張機能など補助ツール周りを整えておくとより書きやすいのかなと思いました。

また今後やりたいことは次のことです。
1. PDFをコマンドで一発で生成できるようにする
2. コンポーネントをUIから挿入できるようなテキストエディターを作る(プレビュー機能付き)
3. mdxをDBで管理するようにして動的出力や検索を可能にする

3については[Next.jsのドキュメント](https://nextjs.org/docs/app/building-your-application/configuring/mdx#remote-mdx)でも触れられていました。
2, 3が実現できれば実務で利用することもかなり現実的になるのではないかと思うのでまた引き続き試してみたいと思います。