23
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-07-10

Markdocとは

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

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

今回はStripeのGitHubにあるサンプルプロジェクトを参考にしながら、イジイジしてみます。

1. 下準備

まずはで新しいNext.jsのプロジェクトを作成します。

npx create-next-app@latest

Markdocのnextjs用のモジュールをインストールします

npm install @markdoc/next.js

ルートディレクトリにあるnext.config.jsにコードを追加します。optionの部分にはmarkdoc用のスキーマの場所やpropsの取得方法を定義できます。→docs

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

各ファイルの中身はGitHubに載せます。詳しくはそちらをご参照下さい。

3. トップページを表示する

markdownのヘッダーをもとにページのタイトルを設定できるように_app.jsをいじります。

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>
    </>
  );
}

ページ上部でナビゲーションを使用しますのでコンポーネントを追加します。

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

pages/index.md
---
title: Markdocをはじめよう
description: Here is the top page!
---

# {% $markdoc.frontmatter.title %}
{% $markdoc.frontmatter.description %}

ファイルを配置したらサーバーを起動してブラウザから開いてみます

npm run dev

↓↓↓
http://localhost:3000
image.png

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

4. Markdocの機能を試す

動きが確認できたので、ドキュメントに沿って色々試します。
まずはMarkdocの標準のNodesを使ってみます。→docs

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

次にTagを使ってみます。→docs
tableタグを使ってみましょう。

pages/docs/firstpost.md
+ {% table %}
+ * タイトル {% align="center" %}
+ * 公開日 {% align="center" %}
+ * 説明 {% align="center" %}
+ ---
+ * {% $markdoc.frontmatter.title %}
+ * {% $markdoc.frontmatter.date %}
+ * {% $markdoc.frontmatter.description %}
+ {% /table %}

↓↓↓
image.png
装飾なしのシンプルなテーブルになってしまいましたが、もちろんCSSを当てて見た目を整えることもできます。

5. 独自機能を追加する

独自の機能を作ってドキュメントの中で使うこともできます。
その場合はmarkdocディレクトリの中に定義ファイルを作成します。
image.png

config.js を作成

独自に定義した機能を Markdoc に読み込ませるためにconfigオブジェクトを置きます。Markdocが内部でこれを勝手に読み込んでくれるようです。→docs
tags, nodes, functions 各ディレクトリ内のentry point(index.js)を読み込んでconfigオブジェクトとしてexportします。

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 を追加

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

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);
  }
};
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
image.png
日付のフォーマットが変更されてます。いい感じですね!

Nodesを追加

Nodesはイマイチよくわかってないのですが、Built-InのNodesの定義をオーバーライドして独自のNodesを作るイメージのようです。→docs
見出し用のHeaderのNodeを変更して、ページ内遷移ができるようにします。

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);
  }
};
pages/docs/secondpost.md
...
+ ## 見出し1
+ ## 見出し2

↓↓↓
Image from Gyazo

Tagを追加

ドキュメントのなかで使うタグを自分で定義できます。→docs
Qiitaにある"note"のようなタグを作ってみました。

これ

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をレンダリングするときに読み込むコンポーネントも定義してあげます。

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 というモジュールを使ってますのでインストールしてください。

npm install react-icons

markdownにタグを追加します。

pages/docs/secondpost.md
...

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

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

+ {% note title="Warning" type="warning" %}
+ これは警告用のボックスです
+ {% /note %}

↓↓↓
image.png
コンポーネントを自分でいじれるので装飾も自由自在です。
markdownからこのような柔軟な表現ができるのはとてもいいですね。

Windowsで開発をしていると独自タグの追加時にエラーになることがあるようなのでご注意ください。以下のエラーが出た場合はMarkdocのバージョンを最新にしてください。
https://github.com/markdoc/markdoc/issues/85
image.png

6. 見た目を整える

機能はひと通り試したので、よりドキュメントっぽくしていきたいと思います。

コンテンツテーブルを追加

記事の右側に記事内のヘッダー部分を集めたテーブルを追加します。Qiitaでもよく見かけるこういうやつです。
image.png

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のコンポーネントを追加します。

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

コードブロックを見やすくする

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

fence のNodesの定義を上書きする

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コンポーネントを追加する

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>
  );
}

上のコンポーネントで Prismjsreact-copy-to-clipboard というモジュールを使ってますのでインストールします。

npm install prismjs react-copy-to-clipboard

↓↓↓
Image from Gyazo

まとめ

以上、Markdocのご紹介でした。ドキュメントサイトや技術ブログとかで活躍しそうな感じですね。
記事では紹介できなかった機能などもありますので、気になった方は是非使ってみて下さい。

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

参考記事など

23
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?