MarkdocとNext.jsでドキュメントサイトを作ってみる

# Markdocとは
MarkdocはStripeがOSS化したドキュメント作成のフレームワークで、markdown記法を使って柔軟にサイトを作成できます。

https://markdoc.io/

Stripeといえば分かりやすいドキュメントで個人的に好感を持ってるサービスですが、そのStripeがドキュメント作成用のフレームワークをOSSで公開したということで試してみました。

今回はStripeのGitHubにある[サンプルプロジェクト](https://github.com/markdoc/markdoc-starter/tree/c24616aa523c8bc9075d7beace0b4061b6ee7061)を参考にしながら、イジイジしてみます。

# 1. 下準備
まずはで新しいNext.jsのプロジェクトを作成します。
```
npx create-next-app@latest
```

Markdocのnextjs用のモジュールをインストールします
```
npm install @markdoc/next.js
```

ルートディレクトリにある`next.config.js`にコードを追加します。optionの部分にはmarkdoc用のスキーマの場所やpropsの取得方法を定義できます。→[docs](https://markdoc.io/docs/nextjs#options)
```javascript:next.config.js
const withMarkdoc = require('@markdoc/next.js');

module.exports = withMarkdoc(/* options */)({
  pageExtensions: ['md', 'mdoc', 'js', 'jsx']
});
```

# 2. ファイルの構成
各ファイル構成は以下の通りにしました。設定ファイルはmarkdocというディレクトリを作りそこにすべて突っ込みます。
**Next.jsでMarkdocを扱う場合はファイルの置き場所やファイル名にある程度制限があるようですので注意してください。**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594998/2026f50b-b0a8-ddc1-4bd4-4b2e40990c5f.png)

各ファイルの中身は[GitHub](https://github.com/kota-imai/markdoc-nextjs-tutorial)に載せます。詳しくはそちらをご参照下さい。

# 3. トップページを表示する
markdownのヘッダーをもとにページのタイトルを設定できるように`_app.js`をいじります。
```jsx:pages/_app.js
import React from 'react';

import Head from 'next/head';
import Link from 'next/link';

import { TopNav } from '../components';

import '../public/globals.css';


const TITLE = 'The first docs of markdoc';
const DESCRIPTION = 'Here is a desctiption statement';
const PUBLISH_DATE = '';

export default function MyApp({ Component, pageProps }) {
  const { markdoc } = pageProps;

  let title = TITLE;
  let description = DESCRIPTION;
  let date = PUBLISH_DATE;
  let tags = {};
  
  // mdファイルのYAMLヘッダー部分の情報を収集
  if (markdoc) {
    if (markdoc.frontmatter.title) {
      title = markdoc.frontmatter.title;
    }
    if (markdoc.frontmatter.description) {
      description = markdoc.frontmatter.description;
    }
    if (markdoc.frontmatter.date) {
      date = markdoc.frontmatter.date;
    }
    if (markdoc.frontmatter.tags) { 
      tags = markdoc.frontmatter.tags;
    }
  }

  return (
    <>
      <Head>
        <title>{title}</title>
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta name="referrer" content="strict-origin" />
        <meta name="title" content={title} />
        <meta name="description" content={description} />
        <meta name="date" content={date} />
        <link rel="shortcut icon" href="/favicon.ico" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <TopNav>
        <Link href="/list">Show all contents</Link>
      </TopNav>
      <div className="page">
        <main className="flex column">
          <Component {...pageProps} />
        </main>
      </div>
      <style jsx>
        {`
          .page {
            position: fixed; 
            top: var(--top-nav-height);
            display: flex;
            width: 100vw;
            flex-grow: 1;
          }
          main {
            overflow: auto;
            height: calc(100vh - var(--top-nav-height));
            flex-grow: 1;
            font-size: 16px;
            padding: 0 2rem 2rem;
          }
        `}
      </style>
    </>
  );
}
```

ページ上部でナビゲーションを使用しますのでコンポーネントを追加します。
```jsx:components/TopNav.js
import React from 'react';
import Link from 'next/link';

export function TopNav({children}) {
  return (
    <nav>
      <Link href="/" className="flex">
        Home
      </Link>
      <section>{children}</section>
      <style jsx>
        {`
          nav {
            top: 0;
            position: fixed;
            width: 100%;
            z-index: 100;
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 1rem;
            padding: 1rem 2rem;
            border-bottom: 1px solid var(--border-color);
          }
          nav :global(a) {
            text-decoration: none;
          }
          section {
            display: flex;
            gap: 1rem;
            padding: 0;
          }
        `}
      </style>
    </nav>
  );
}
```
次にドキュメントの中身を作成します。markdownファイルを作成して正しくレンダリングされるか見てみましょう。
本文の`$markdoc.frontmatter` にはYAMLヘッダーの情報が入ってきます。markdown内からは以下のようにアクセスできます。→[docs](https://markdoc.io/docs/frontmatter#access-frontmatter-values)
```markdown:pages/index.md
---
title: Markdocをはじめよう
description: Here is the top page!
---

# {% $markdoc.frontmatter.title %}
{% $markdoc.frontmatter.description %}
```
ファイルを配置したらサーバーを起動してブラウザから開いてみます
```bash
npm run dev
```
↓↓↓
http://localhost:3000
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594998/43dbd7cf-57f4-0575-e062-be76a79ac989.png)

きちんと表示されました。titleにも情報が反映されてるので問題なさそうです。

# 4. Markdocの機能を試す
動きが確認できたので、ドキュメントに沿って色々試します。
まずはMarkdocの標準のNodesを使ってみます。→[docs](https://markdoc.io/docs/syntax#nodes)
```markdown:pages/docs/firstpost.md
---
title: 標準のNodeやTagを試す
description: this is the first post
date: '2022-01-01'
tags: 
  - 'JavaScript'
  - 'Markdoc'
---
投稿日：{% $markdoc.frontmatter.date %}
# {% $markdoc.frontmatter.title %}

# Header

**Bold**

_Italic_

[Links](/)

![Images](/logo.svg)

Lists
- Item 1
- Item 1
- Item 1

> 引用

`Inline code`

```
↓↓↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594998/20904b46-0e14-709f-798a-156492a36ac6.png)


次にTagを使ってみます。→[docs](https://markdoc.io/docs/syntax#tags)
tableタグを使ってみましょう。
```diff_markdown:pages/docs/firstpost.md
+ {% table %}
+ * タイトル {% align="center" %}
+ * 公開日 {% align="center" %}
+ * 説明 {% align="center" %}
+ ---
+ * {% $markdoc.frontmatter.title %}
+ * {% $markdoc.frontmatter.date %}
+ * {% $markdoc.frontmatter.description %}
+ {% /table %}
```
↓↓↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594998/478469cc-b3be-8c51-8633-19b5afce92c0.png)
装飾なしのシンプルなテーブルになってしまいましたが、もちろんCSSを当てて見た目を整えることもできます。


# 5. 独自機能を追加する
独自の機能を作ってドキュメントの中で使うこともできます。
その場合はmarkdocディレクトリの中に定義ファイルを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594998/f04117a2-255d-511c-377e-4a6daf38d434.png)

### config.js を作成
独自に定義した機能を Markdoc に読み込ませるためにconfigオブジェクトを置きます。Markdocが内部でこれを勝手に読み込んでくれるようです。→[docs](https://markdoc.io/docs/syntax#config)
`tags`, `nodes`, `functions` 各ディレクトリ内のentry point(index.js)を読み込んでconfigオブジェクトとしてexportします。
```javascript: markdoc/config.js
import tags from './tags';
import nodes from './nodes';
import functions from './functions';

export const config = {
  tags,
  nodes,
  functions,
  // add other stuff here
};
```
### function を追加

投稿日の日付フォーマットを変更するファンクションを定義します。

```javascript: markdoc/functions/toLocaleDate.js
// エラーハンドルとかしてません。すんません。
export const toLocaleDateJP= {
  transform(parameters) {
    const strDate = parameters[0];
    const arr = strDate.split("-");
    const date = new Date(arr[0], arr[1]-1, arr[2]);
    const option = { weekday: 'narrow', year: 'numeric', month: 'long', day: 'numeric' }
    return date.toLocaleDateString('ja-JP', option);
  }
};

export const toLocaleDateUS= {
  transform(parameters) {
    const strDate = parameters[0];
    const arr = strDate.split("-");
    const date = new Date(arr[0], arr[1] - 1, arr[2]);
    const option = { weekday: 'short', year: 'numeric', month: 'long', day: 'numeric' }
    return date.toLocaleDateString('en-US', option);
  }
};
```

```markdown: pages/docs/secondpost.md
---
title: 機能を拡張してみる
description: this is the second post
date: '2022-02-01'
tags: 
  - 'JavaScript'
  - 'Markdoc'
---
投稿日: {% toLocaleDateJP($markdoc.frontmatter.date) %}  
Published: {% toLocaleDateUS($markdoc.frontmatter.date) %}

# {% $markdoc.frontmatter.title %}
```
↓↓↓
[http://localhost:3000/docs/secondpost](http://localhost:3000/docs/secondpost)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594998/58bb3ffb-7f8d-cba2-6b56-f46862cd903b.png)
日付のフォーマットが変更されてます。いい感じですね！

### Nodesを追加
Nodesはイマイチよくわかってないのですが、[Built-InのNodes](https://markdoc.io/docs/nodes#built-in-nodes)の定義をオーバーライドして独自のNodesを作るイメージのようです。→[docs](https://markdoc.io/docs/nodes)
見出し用のHeaderのNodeを変更して、ページ内遷移ができるようにします。

```javascript: markdoc/nodes/heading.js
import { Tag } from '@markdoc/markdoc';
import { Heading } from '../../components';

// 要素に付与するidを生成する
function generateID(children, attributes) {
  if (attributes.id && typeof attributes.id === 'string') {
    return attributes.id;
  }
  return children
    .filter((child) => typeof child === 'string')
    .join(' ')
    .replace(/[?]/g, '')
    .replace(/\s+/g, '-')
    .toLowerCase();
}

export const heading = {
  render: Heading,
  children: ['inline'],
  attributes: {
    id: { type: String },
    level: { type: Number, required: true, default: 1 },
    className: { type: String }
  },
  transform(node, config) {
    const attributes = node.transformAttributes(config);
    const children = node.transformChildren(config);
    const id = generateID(children, attributes);

    return new Tag(this.render, { ...attributes, id }, children);
  }
};
```

```diff_markdown:pages/docs/secondpost.md
...
+ ## 見出し1
+ ## 見出し2
```
↓↓↓
[![Image from Gyazo](https://i.gyazo.com/ae99a794b41d06ba6f8310fa96e956b9.gif)](https://gyazo.com/ae99a794b41d06ba6f8310fa96e956b9)

### Tagを追加
ドキュメントのなかで使うタグを自分で定義できます。→[docs](https://markdoc.io/docs/tags#create-a-custom-tag)
Qiitaにある"note"のようなタグを作ってみました。

::: note
これ
:::

```javascript: markdoc/tags/note.js
import { Note } from '../../components/Note';

export const note = {
  render: Note,
  description: 'Display the enclosed content in a note box',
  children: ['paragraph', 'tag', 'list'],
  attributes: {
    type: {
      type: String,
      default: 'note', // typeを指定しない場合は 'note'
      matches: ['caution', 'check', 'note', 'warning'], // Noteボックスのタイプを指定できる
      errorLevel: 'critical',
      description:
        'Controls the design of the box. "caution", "check", "note", "warning"'
    },
    title: {
      type: String,
      description: 'The title displayed at the top of the box'
    }
  }
};
```
このTagをレンダリングするときに読み込むコンポーネントも定義してあげます。
```jsx: components/Note.js
import React from 'react';

import { FaInfoCircle, FaCheckCircle, FaExclamationTriangle } from 'react-icons/fa';

export const Note = ({ title, type, children }) => {
  let bgColor = '#e3f7df';
  let iconColor = '#55c500';
  if (type === 'caution') {
    bgColor = '#fdf9e2';
    iconColor = '#f7a535';
  }
  if (type === 'warning') {
    bgColor = '#feebee'; 
    iconColor = '#d60a34';
  }
  return (
    <div className="note">
      <div className="icon">
        {type === 'check' ? <FaCheckCircle /> : type === 'warning' ? <FaExclamationTriangle /> : <FaInfoCircle />}
      </div>
      <div className="message">
        <strong>{title}</strong>
        <span>{children}</span>
      </div>
      <style jsx>
        {`
          .note {
            display: flex;
            justify-content: flex-start;
            align-items: start;
            padding: 12px 16px;
            margin: 4px;
            background: ${bgColor};
            border-radius: 4px;
          }
          .note :global(p) {
            margin: 0;
          }
          .note > .message {
            display: flex;
            flex-direction: column;
          }
          .note > .icon {
            flex-basis: 28px;
            color: ${iconColor};
          }
        `}
      </style>
    </div>
  );
}
```
上のコンポーネントで [React Icons](https://react-icons.github.io/react-icons/) というモジュールを使ってますのでインストールしてください。
```bash
npm install react-icons
```
markdownにタグを追加します。
```diff_markdown:pages/docs/secondpost.md
...

+ {% note title="Info"%}
+ これはInfo用のボックスです
+ {% /note %}

+ {% note title="Caution" type="caution"%}
+ これは注意書き用のボックスです
+ {% /note %}

+ {% note title="Warning" type="warning" %}
+ これは警告用のボックスです
+ {% /note %}
```
↓↓↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594998/703ff0ef-d20e-d5de-c21d-36289c26feb5.png)
コンポーネントを自分でいじれるので装飾も自由自在です。
markdownからこのような柔軟な表現ができるのはとてもいいですね。

::: note warn
Windowsで開発をしていると独自タグの追加時にエラーになることがあるようなのでご注意ください。以下のエラーが出た場合はMarkdocのバージョンを最新にしてください。
https://github.com/markdoc/markdoc/issues/85
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594998/5ba37608-cd36-1530-c6bc-181e71ce317a.png)
:::

# 6. 見た目を整える
機能はひと通り試したので、よりドキュメントっぽくしていきたいと思います。

### コンテンツテーブルを追加
記事の右側に記事内のヘッダー部分を集めたテーブルを追加します。Qiitaでもよく見かけるこういうやつです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594998/935c9ba7-bea5-7da7-d6ee-888cb80e7aad.png)

```diff_jsx:pages/_app.js
import React from 'react';

import Head from 'next/head';
import Link from 'next/link';

+ import { TableOfContents, TopNav } from '../components';

import '../public/globals.css';


const TITLE = 'The first docs of markdoc';
const DESCRIPTION = 'Here is a desctiption statement';
const PUBLISH_DATE = '';

+ function collectHeadings(node, sections = []) {
+   if (node) {
+     if (node.name === 'Heading') {
+       const title = node.children[0];
+       if (typeof title === 'string') {
+         sections.push({
+           ...node.attributes,
+           title
+         });
+       }
+     }
+     if (node.children) {
+       for (const child of node.children) {
+         collectHeadings(child, sections);
+       }
+     }
+   }
+   return sections;
+ }

export default function MyApp({ Component, pageProps }) {
  const { markdoc } = pageProps;

  let title = TITLE;
  let description = DESCRIPTION;
  let date = PUBLISH_DATE;
  let tags = {};
  if (markdoc) {
    if (markdoc.frontmatter.title) {
      title = markdoc.frontmatter.title;
    }
    if (markdoc.frontmatter.description) {
      description = markdoc.frontmatter.description;
    }
    if (markdoc.frontmatter.date) {
      date = markdoc.frontmatter.date;
    }
    if (markdoc.frontmatter.tags) { 
      tags = markdoc.frontmatter.tags;
    }
  }

+ const toc = pageProps.markdoc?.content
+   ? collectHeadings(pageProps.markdoc.content)
+   : [];

  return (
    <>
      <Head>
        <title>{title}</title>
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta name="referrer" content="strict-origin" />
        <meta name="title" content={title} />
        <meta name="description" content={description} />
        <meta name="date" content={date} />
        <link rel="shortcut icon" href="/favicon.ico" />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <TopNav>
        <Link href="/list">Show all contents</Link>
      </TopNav>
      <div className="page">
        <main className="flex column">
          <Component {...pageProps} />
        </main>
+       <TableOfContents toc={toc} />
      </div>
 ...
    </>
  );
}
```
TableOfContentsのコンポーネントを追加します。
```jsx: components/TableOfContents.js
import React from 'react';
import Link from 'next/link';

export function TableOfContents({toc}) {
  const items = toc.filter(
    (item) => item.id && (item.level === 2 || item.level === 3)
  );

  if (items.length <= 1) {
    return null;
  }

  return (
    <nav className="toc">
      <ul className="flex column">
        {items.map((item) => {
          const href = `#${item.id}`;
          const active =
            typeof window !== 'undefined' && window.location.hash === href;
          return (
            <li
              key={item.title}
              className={[
                active ? 'active' : undefined,
                item.level === 3 ? 'padded' : undefined,
              ]
                .filter(Boolean)
                .join(' ')}
            >
              <Link href={href} passHref>
                <a>{item.title}</a>
              </Link>
            </li>
          );
        })}
      </ul>
      <style jsx>
        {`
          nav {
            position: sticky;
            top: calc(2.5rem + var(--top-nav-height));
            max-height: calc(100vh - var(--top-nav-height));
            flex: 0 0 auto;
            align-self: flex-start;
            margin-bottom: 1rem;
            padding: 0.5rem 0 0;
            border-left: 1px solid var(--border-color);
          }
          ul {
            margin: 0;
            padding: 0 1.5rem;
          }
          li {
            list-style-type: none;
            margin: 0 0 1rem;
          }
          li a {
            text-decoration: none;
          }
          li a:hover,
          li.active a {
            text-decoration: underline;
          }
          li.padded {
            padding-left: 1rem;
          }
        `}
      </style>
    </nav>
  );
}
```
↓↓↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594998/9913601b-2ec6-9bbc-768d-6356f15d5632.png)

### コードブロックを見やすくする
ソースコードを表示するコードブロックを変更します。シンタックスハイライトやコピーボタンの機能を追加してみます。

fence のNodesの定義を上書きする
```javascript: markdoc/nodes/fence.js
import { CodeBlock } from "../../components";

export const fence = {
  render: CodeBlock,
  attributes: {
    content: {type: String},
    language: {
      type: String,
      description:
        'The programming language of the code block. Place it after the backticks.',
    },
  },
};
```
CodeBlockコンポーネントを追加する
```jsx: components/CodeBlock.js
import React from 'react';

import Prism from 'prismjs';
import 'prismjs/components/prism-bash.min';
import 'prismjs/components/prism-javascript.min';
import 'prismjs/themes/prism-tomorrow.css';
import 'prismjs/plugins/inline-color/prism-inline-color.min';
import { CopyToClipboard } from "react-copy-to-clipboard";
import { FaRegClone } from "react-icons/fa";

export function CodeBlock({children, language}) {
  
  const [isCopied, setIsCopied] = React.useState(false);

  const onCopyClicked = () => {
    setIsCopied(true);
    setTimeout(() => {
      setIsCopied(false);
    }, 4000);
  };
  
  const ref = React.useRef(null);
  
  React.useEffect(() => {
    if (ref.current) Prism.highlightElement(ref.current, false);
  }, [children]);
  
  return (
    <div className="code" aria-live="polite">
      <CopyToClipboard text={children}>
        <button onClick={onCopyClicked} className="copy-button">
          <span>{isCopied ? "copied!" : <FaRegClone />}</span>
        </button>
      </CopyToClipboard>    
      <pre
        ref={ref}
        className={`language-${language}`}
      >
        {children}
      </pre>
      <style jsx>
        {`
          .copy-button {
            position: absolute;
            right: 0px;
            border: 0px;
            background: transparent;
            color: #f2f2f2;
            padding: 12px;
            font-size: 1em;
            cursor: pointer;
          }
          
          .code {
            position: relative;
            margin: 4px;
          }

          /* Override Prism styles */
          .code :global(pre[class*='language-']) {
            text-shadow: none;
            border-radius: 4px;
          }
          
        `}
      </style>
    </div>
  );
}
```
上のコンポーネントで [Prismjs](https://www.npmjs.com/package/prismjs) と [react-copy-to-clipboard](https://www.npmjs.com/package/react-copy-to-clipboard) というモジュールを使ってますのでインストールします。
```bash
npm install prismjs react-copy-to-clipboard
```
↓↓↓
[![Image from Gyazo](https://i.gyazo.com/dfb27a19db7836b1d472375548113a73.gif)](https://gyazo.com/dfb27a19db7836b1d472375548113a73)
# まとめ
以上、Markdocのご紹介でした。ドキュメントサイトや技術ブログとかで活躍しそうな感じですね。
記事では紹介できなかった機能などもありますので、気になった方は是非使ってみて下さい。

今回使ったソースコードはGitHubに置いてあります。間違ってるところなどあるかもなので、見つけたらそっと教えてください。最後まで読んで頂きありがとうございました。

https://github.com/kota-imai/markdoc-nextjs-tutorial

https://markdoc-nextjs-tutorial.vercel.app/

# 参考記事など
https://github.com/markdoc/markdoc-starter/tree/c24616aa523c8bc9075d7beace0b4061b6ee7061

https://markdoc.io/docs/nextjs

https://dev.to/stripe/getting-started-with-markdoc-in-nextjs-ioj