Next.js × microCMSでMarkdownの入稿環境を整えてみよう！

## はじめに
こんにちは！

<font color="red"> :santa:**DMM WEBCAMP Advent Calendar 2023** :christmas_tree:</font>の**23日目**を担当する、 @Keichan_15 です！

普段はメンターとして受講生さんの学習サポートをさせて頂いています！
かれこれ2年目に突入しました:v:

去年に引き続き、今年もありがたいことにカレンダー執筆の機会を頂きました。
今日から私の方で3日間連続で公開予定ですので、お楽しみに！:relaxed:

https://qiita.com/advent-calendar/2023/infratop

## 2発目は何書くの！
さて、今年2発目の記事は **【Next.js × microCMSでMarkdownの入稿環境を整える】** です！
実はこちらの記事、昨日(22日目)を担当した @takakou の続きの記事となっております！

僕も実はmicroCMSを勧められて…使ってみたら本当に便利でビックリ…(まだレイアウトが完成しきれず公開できていませんが…)

一応途中経過はこんな感じ… ↓ 結構良さげじゃない？:thinking:

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/5e0049b4-9cb6-6a2e-0809-9a1ecd00b72e.png)

ただそんな便利なmicroCMSにも1つだけ難点がありました。
それは**Markdown専用のエディターが用意されていない**んです！:crying_cat_face:

これ僕にとってはめちゃくちゃ致命的で、技術記事のMarkdownをそのまま移植できるようにしたかったんですよね。

ただ**デフォルトだとmicroCMSはリッチテキストエディタになる**ので、**単純コピペでは反映されない**という~~大大大 × 100~~欠点がありました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/771abe3c-2a8a-80a3-ede5-b583006245f0.png)

悲しい…:disappointed_relieved:

今回はそんな問題を解決する為に、**microCMSの拡張フィールドを使用してMarkdownでの入稿環境を構築**してみたいと思います！

早速やっていきましょ～～！

## 環境
- **Next.js(v13.5.4)**
- **Typescript5.0**
- **React18**
- **microCMS**

環境自体は22日目の記事で構築したものをそのまま使っています！
~~ということは22日目を見ないと分からないってことだゾ！~~

普段はDockerで構築していますが、今回の記事用に別で用意するのが面倒だったのでWSL2を使いました。

Dockerでブログ環境を構築してえ…って方は[こちら](https://qiita.com/Keichan_15/items/4fc605895fef2a33b629)の記事見てサクッと構築するのもアリですね！ ~~(宣伝)~~

https://qiita.com/Keichan_15/items/4fc605895fef2a33b629

## 導入準備
今回の導入方法はmicroCMSの公式ブログを参考にさせて頂いています。

https://blog.microcms.io/field-extension-markdown-editor/


それでは早速Markdownの入力フォームを導入していきましょう。

イメージとしてはNext.js側でMarkdownの入力フォームを作成し、そこで入力したデータをmicroCMS側で形式を崩さず保存するといった方法になります。

まずは必要なライブラリをインストールしていきます。

```terminal:terminal
$ npm install next-remove-imports @uiw/react-md-editor
```

次に`next.config.js`を以下のように編集します。

```js:next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
};

const removeImports = require("next-remove-imports")();

module.exports = removeImports({
  ...nextConfig,
});
```

`client.ts`も併せて修正します。

```diff_typescript:libs/client.ts
import { createClient } from 'microcms-js-sdk';

export type Blog = {
    id: string;
    title: string;
+   content: string;   
}

if (!process.env.SERVICE_DOMAIN) {
    throw new Error("MICROCMS_SERVICE_DOMAIN is required");
}

if (!process.env.API_KEY) {
    throw new Error("MICROCMS_SERVICE_DOMAIN is required");
}

export const client = createClient({
    serviceDomain: process.env.SERVICE_DOMAIN,
    apiKey: process.env.API_KEY,
});

// ブログ一覧を取得
export const getBlogs = async () => {
    const blogs = await client.getList<Blog>({
    endpoint: "blogs"
    });
    return blogs;
}

// ブログの詳細を取得
export const getDetail = async (contentId: string) => {
    const blog = await client.getListDetail<Blog>({
        endpoint: "blogs",
        contentId,
    });
    return blog;
};
```

最後にmicroCMSの拡張フィールドを利用するため、`microcms-field-extension-react`もインストールしておきます。

```terminal:terminal
$ npm install microcms-field-extension-react
```

これで導入準備が完了しました！便利だ～～:raised_hands:

## Markdown Editor 実装
次にMarkdown Editorの表示部を実装していきます！

今回は表示部用のページとして、`articles/new/page.tsx`を作成し、そちらにMarkdown Editorを表示させるようにします。

```tsx:articles/new/page.tsx
"use client";

import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { useFieldExtension } from "microcms-field-extension-react";
import "@uiw/react-md-editor/markdown-editor.css";
import "@uiw/react-markdown-preview/markdown.css";

// dynamic importを使用してMarkdown Editor for Reactを読み込む
const MDEditor = dynamic(() => import("@uiw/react-md-editor"), {
  ssr: false,
  loading: () => <div>initializing...</div>,
});

// 自身が利用しているmicroCMSのURLを設定
const origin = "https://hogehoge.microcms.io";

const IndexPage = () => {
  const [markdown, setMarkdown] = useState<string | undefined>();
  // microCMSのフィールド拡張を利用するためのhook
  const { data, sendMessage } = useFieldExtension("", {
    origin,
    height: 540,
  });

  useEffect(() => {
    if (!markdown) {
      setMarkdown(data);
    }
  }, [data, markdown]);

  return (
    <div data-color-mode="light">
      <MDEditor
        value={markdown}
        onChange={(value) => {
          setMarkdown(value);
          sendMessage({
            data: value,
          });
        }}
        height={540}
        textareaProps={{
          placeholder: "Please enter Markdown text",
        }}
      />
    </div>
  );
};

export default IndexPage;
```

実装が完了したら、microCMSを設定していきます。
microCMSにアクセスし、APIスキーマを開きましょう。

今回分かりやすいように、title以外のスキーマは削除しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/31bb2658-30b9-c342-867d-f9e05eee967f.png)

`フィールドを追加`をクリック。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/227888dd-f8c1-66a2-95a5-d5266fcc86ff.png)

各種設定は以下のように入力しました。

- `フィールドID`: **content**
- `表示名`: **内容**

次に「種類」から**拡張フィールド**を選択します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/b601d0a5-3005-90f4-0749-927a39374222.png)

**読み込み先URL**の入力が求められるため、先ほどMarkdown Editorを設定したページのURLを貼り付けます。

今回であれば`http://localhost:3000/articles/new`が読み込み先URLとなります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/fa5938fa-9c6d-bb5f-7045-52e564e2d059.png)

URLの入力が完了したら、`変更する`をクリックして保存しましょう！

`追加`をクリックし新規ブログのページに遷移後、Markdown Editorが表示されていればOKです！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/1324adeb-f6af-c66d-c630-44189ade7874.png)

仮のブログデータを作成し、`公開`をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/10d4105c-aa35-7f8e-bb2c-9afa7dd756c0.png)

テスターで試しに叩いてみると、しっかりとMarkdown Editorに入力された内容が取得できていることを確認できました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/bc5cb922-9b53-83ce-6a91-791d8e8b229e.png)

## Markdownを表示させていく！
Markdownの入稿環境が整ったので、次は入力したMarkdownをNext.js側で表示させていこうと思います！

ブログの表示ページは`blogs/[blogId]/page.tsx`にしました！
ぶっちゃけどのページでも構いません！ご自身の表示させたい所で実装してください。

まず以下のライブラリ類をインストールしていきます。

- `zenn-markdown-html`
MarkdownのコンテンツをHTMLに変換

https://github.com/zenn-dev/zenn-editor

```terminal:terminal
$ npm i zenn-markdown-html
```

- `cheerio`
jQueryチックな書き方できるやつ、下に紹介の`highlight.js`を使用する為に導入

https://cheerio.js.org/

```terminal:terminal
$ npm i cheerio
```

- `highlight.js`
シンタックスハイライト適用。コードに色が付くよ！

https://highlightjs.org/

```terminal:terminal
$ npm i highlight.js
```

ここから好きなパレットを選べます。

https://highlightjs.org/examples

今回は`night-owl.css`を使用します！この辺りは好みの問題ですね:thinking:

それではブログの詳細画面を実装していきましょう！

```tsx:blogs/[blogId]/page.tsx
import { notFound } from "next/navigation";
import { getDetail, getBlogs } from "@/../libs/client";
import markdownHtml from "zenn-markdown-html";
import { load } from "cheerio";
import hljs from "highlight.js";
import "highlight.js/styles/night-owl.css";

export async function generateStaticParams() {
  const { contents } = await getBlogs();
  console.log(contents);

  const paths = contents.map((post) => {
    return {
      postId: post.id,
    };
  });
  return [...paths];
}

export default async function StaticDetailPage({
  params: { blogId },
}: {
  params: { blogId: string };
}) {

  // ブログデータを取得
  const post = await getDetail(blogId);

  if (!post) {
    notFound();
  }

  // Markdown部分をHTMLに変換
  let html = markdownHtml(post.content);

  const $ = load(html);

  $("pre code").each((_, elm) => {
    const result = hljs.highlightAuto($(elm).text());
    $(elm).html(result.value);
    $(elm).addClass("hljs");
  });
  html = $.html();

  return (
    <div className="lg:col-span-2">
      <div className="flex flex-wrap w-full mb-20 flex-col items-center text-center">
        <h1 className="sm:text-3xl text-2xl font-medium title-font mb-2 text-gray-900">
          hoge hoge Tech Blog
        </h1>
        <p className="lg:w-1/2 w-full leading-relaxed text-gray-500">
          Qiita Test
        </p>
      </div>
      <div
        className="markdown"
        dangerouslySetInnerHTML={{ __html: html }}
      ></div>
    </div>
  );
}
```

実装自体は意外とすんなり書けました。:writing_hand:
基本的にこちらをこのままコピペで問題無いです！

次はCSSを当てていきましょう！

今回は採用しませんでしたが、特にこだわりが強い訳では無い場合、`zenn-content-css`を使うのもアリです(CSSをZennに寄せたければ一番手っ取り早い)

https://github.com/zenn-dev/zenn-editor#2-css-%E3%82%92%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%82%80

今回は`highlight.js`を使用しているので、出来るところは自分でゴリゴリ書いています。今回ある程度の部分は @takakou の技術ブログのCSSをパクってきましたw(本人了承済)

~~ほんまにキツイCSS~~

```css:globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

div.code-block-container {
  margin-top: 20px;
}

span.code-block-filename {
  color: #2c3e50; /* Matching text color */
  background-color: #ecf0f1;
  padding: 0.25rem 0.5rem;
  border-bottom-right-radius: 5px;
  font-size: 0.75rem;
  font-weight: bold;
  position: absolute;
}

.markdown h1 {
  margin-top: 50px;
  font-size: 1.9rem;
  font-weight: bold;
  margin-bottom: 3rem;
  color: #2c3e50; /* Maintaining the sophisticated look */
}

.markdown h2 {
  margin-top: 30px;
  font-size: 1.5rem;
  font-weight: bold;
  margin-top: 2rem;
  margin-bottom: 2rem;
  border-left: 3px solid #323232; /* Vibrant blue border */
  padding-left: 0.5rem;
  color: #2c3e50; /* Matching color */
  background-color: #ffffff; /* Subtle grey background */
}

.markdown h3 {
  font-weight: bold;
  text-decoration: underline;
  margin-top: 20px;
  font-size: 1.3rem;
  margin-top: 1.5rem;
  margin-bottom: 0.5rem;
  color: #2c3e50; /* Matching color */
}

.markdown h4 {
  font-weight: bold;
  font-size: 1.1rem;
  margin-top: 1.2rem;
  margin-bottom: 0.3rem;
  color: #2c3e50; /* Matching color */
}

.markdown ul {
  list-style-type: disc;
  margin-left: 2rem;
  padding-left: 10px;
}

.markdown li {
  font-weight: bold;
  padding: 3px;
}

.markdown ol {
  list-style-type: decimal;
  margin-left: 2rem;
}

.markdown a {
  color: #3498db; /* Blue link color */
  text-decoration: none;
  transition: color 0.2s;
}

.markdown a:hover {
  color: #2980b9; /* Slightly darker blue on hover */
  text-decoration: underline;
}

.markdown pre {
  background-color: #2c3e50; /* Darker grey background */
  padding: 1rem;
  padding-top: 1rem;
  padding-bottom: 0.1rem;
  border-radius: 5px;
  overflow-x: auto;
  color: #ecf0f1; /* Light text color */
  margin-bottom: 30px;
  /* margin-top: 30px; */
}

.markdown pre > code {
  background-color: #2c3e50;
  padding: 0.2rem 0.4rem;
  border-radius: 4px;
  font-family: "Fira Code", monospace; /* Monospace font for code */
  color: #ecf0f1; /* Light text color */
}

.markdown div {
  position: relative;
}

.markdown code {
  background-color: #eaecee;
  padding: 0.2rem 0.4rem;
  border-radius: 4px;
  font-family: "Fira Code", monospace; /* Monospace font for code */
  color: #c0392b; /* Red text color */
}

.markdown div div[data-filename]::before {
  /* content: attr(data-filename);
  color: #2c3e50; 
  background-color: #ecf0f1; 
  padding: 0.25rem 0.5rem;
  border-top-left-radius: 5px;
  border-bottom-right-radius: 5px;
  font-size: 0.75rem;
  font-weight: bold;
  position: absolute; */
}

.markdown blockquote {
  border-left: 5px solid #3498db; /* Matching blue border */
  padding-left: 1rem;
  margin: 10px;
  margin-left: 2rem;
  color: #7f8c8d; /* Slightly darker grey text color */
  font-style: italic;
}

.markdown hr {
  border: none;
  border-top: 2px solid #ecf0f1; /* Light grey border */
  margin: 1.5rem 0;
}

.markdown img {
  max-width: 100%;
  height: auto;
  display: block;
  margin: 1.5rem auto;
  border-radius: 5px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* Light shadow */
}

.markdown p {
  padding-left: 10px;
}

@media (max-width: 768px) {
  .markdown h1 {
    font-size: 1.3rem;
  }

  .markdown h2 {
    font-size: 1.1rem;
  }

  .markdown h3 {
    font-size: 0.9rem;
  }

  .markdown h4 {
    font-size: 0.8rem;
  }

  .markdown ul {
    margin-left: 1rem;
  }

  .markdown ol {
    margin-left: 1rem;
  }

  .markdown pre {
    font-size: 0.9rem;
  }

  .markdown code {
    font-size: 0.9rem;
  }

  .markdown blockquote {
    font-size: 1rem;
  }
}

.timeline {
  list-style: none;
}
.timeline > li {
  margin-bottom: 60px;
}

/* for Desktop */
@media (min-width: 640px) {
  .timeline > li {
    overflow: hidden;
    margin: 0;
    position: relative;
  }
  .timeline-date {
    width: 110px;
    float: left;
    margin-top: 20px;
  }
  .timeline-content {
    width: 75%;
    float: left;
    border-left: 3px #e5e5d1 solid;
    padding-left: 30px;
    padding-top: 20px;
  }
  .timeline-content:before {
    content: "";
    width: 12px;
    height: 12px;
    background: #837ccf;
    position: absolute;
    left: 106px;
    top: 24px;
    border-radius: 100%;
  }
}
```

実装が完了したら、`http://localhost:3000/blogs/(コンテンツID)` を入力して画面に遷移してみましょう！

コンテンツIDの確認方法はシンプルで、microCMSにアクセスして先ほど作成したブログをクリックすると以下の詳細画面が開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/2a73abc0-482e-81de-69d3-b94eed809484.png)

左上に`コンテンツID`が記載されています。こちらをURLに設定してください。
画像で言うところの`dq2wc7jtt`になります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/72da2109-7d70-2135-c3a9-ed2f58c94132.png)

画面が開き、以下のようなページが表示されていればOKです！お疲れ様でした！:v:

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/04158726-17cb-ef3c-83f0-11191e418b67.png)

ちなみにコードブロックでコードを書いていた場合、表示は以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/95c225a0-3fd5-9bd4-9785-513e5f36395a.png)

ファイル名表示にも対応させています。これ何かとしんどかったです（笑）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/99fb0975-49d3-2b31-c711-366cc7288969.png)


めちゃくちゃ良い感じじゃない？？:relaxed:

色合い等々に関しては、CSSをある程度触ったことのある方でしたら `globals.css` を弄ることで自由自在に変更できるので、是非ご自身のお好きなレイアウトにしてみてくださいね！

ちなみに今回は時間の都合上書きませんでしたが、**Cloudflare R2**を使用すれば画像をドラッグ & ドロップで投稿できるようにもなります！
~~これは僕が元気であれば年明けに書くかもしれません、あまり期待しないで！~~

皆さんも是非世界に1つだけのブログを作ってみましょう！！

## 参考

https://blog.microcms.io/field-extension-markdown-editor/

https://zenn.dev/pilefort/articles/d3c047557c540c

## おわりに
いかがでしたでしょうか。

今回はNext.jsとmicroCMSを使用して、Markdownの入稿環境を構築してみました！
Qiitaの記事もそうですが、Markdownって1回書いたらもうMarkdown以外書けなくなりません！？ってくらい良いので、是非皆さんも実践してみて頂けると幸いです！:bow:

明日24日目も私が担当させて頂きました！
原点に戻りRailsにまつわる記事を書かせて頂きましたので、良ければ見てくださると泣いて喜びます！:joy:

最後までご覧頂き、ありがとうございました！！