LoginSignup
2
0

Contentlayer で目次を生成する ほか

Last updated at Posted at 2023-12-02

  • Contentlayer で Markdown を使うにあたって、 ToC を生成したかったのですが、既存のプラグインではイマイチだったので、computedFieldsにゴリ押しで実装した事例です。
  • その他、いろいろなプラグインの事例も備忘録的に記載しています。

Contentlayer

  • Markdown またはMDX 形式のドキュメントを読み込み、型が付いたオブジェクト配列として扱えるようにしてくれるツールです。
    • Notionが試験的にサポートされていたり、Contentfulほかヘッドレス CMS のサポートを計画しているようです。

例えば

これが

---
title: 例えば
date: 2023-12-01
emoji: 📝
description: 第一というと、あなたの胸にはまた失われてしまうのだそうです。
tags: 
  - Lorem Ipsum
  - Next.js
  - Tailwind CSS
  - Contentlayer
---

## もっともその時の私の判断に縋り付こうとしたのです。

それは前後で丁度三、四日続いて出た。奥さんの名は静といったのです。私はその時自分の言葉使いの角張ったところに気が付くはずがなかった。

- 岩の上から見下す水は、また極めて簡単でした。
- しかも細君は夫が寝ている父を見た。
- それが解るくらいなら私だって同じ事であった。

## しかし……私はこの質問の裏にそっと置かれた。

もう何にもする事はありました。私はこの間中聞いたのです。そのくらいだから父の危険が眼の前に涙ぐんだ。

1. 人間を愛し得る人、愛せずにはいられませんでした。
2. 私は何とも思いませんでした。
3. するとKは足が向かなくなります。

こんな感じになります。

{
  "title": "例えば",
  "description": "第一というと、あなたの胸にはまた失われてしまうのだそうです。",
  "date": "2023-12-01T00:00:00.000Z",
  "emoji": "📝",
  "tags": [
    "Lorem Ipsum",
    "Next.js",
    "Tailwind CSS",
    "Contentlayer\r"
  ],
  "body": {
    "raw": "\r\n## もっともその時の私の判断に縋り付こうとしたのです。\r\n\r\nそれは前後で丁度三、四日続いて出た。奥さんの名は静といったのです。私はその時自分の言葉使いの角張ったところに気が付くはずがなかった。\r\n\r\n- 岩の上から見下す水は、また極めて簡単でした。\r\n- しかも細君は夫が寝ている父を見た。\r\n- それが解るくらいなら私だって同じ事であった。\r\n\r\n## しかし……私はこの質問の裏にそっと置かれた。\r\n\r\nもう何にもする事はありました。私はこの間中聞いたのです。そのくらいだから父の危険が眼の前に涙ぐんだ。\r\n\r\n1. 人間を愛し得る人、愛せずにはいられませんでした。\r\n2. 私は何とも思いませんでした。\r\n3. するとKは足が向かなくなります。\r\n",
    "html": "<h2 id=\"もっともその時の私の判断に縋り付こうとしたのです\">もっともその時の私の判断に縋り付こうとしたのです。<a aria-hidden=\"true\" tabindex=\"-1\" href=\"#もっともその時の私の判断に縋り付こうとしたのです\"><span class=\"icon icon-link\"></span></a></h2>\n<p>それは前後で丁度三、四日続いて出た。奥さんの名は静といったのです。私はその時自分の言葉使いの角張ったところに気が付くはずがなかった。</p>\n<ul>\n<li>岩の上から見下す水は、また極めて簡単でした。</li>\n<li>しかも細君は夫が寝ている父を見た。</li>\n<li>それが解るくらいなら私だって同じ事であった。</li>\n</ul>\n<h2 id=\"しかし私はこの質問の裏にそっと置かれた\">しかし……私はこの質問の裏にそっと置かれた。<a aria-hidden=\"true\" tabindex=\"-1\" href=\"#しかし私はこの質問の裏にそっと置かれた\"><span class=\"icon icon-link\"></span></a></h2>\n<p>もう何にもする事はありました。私はこの間中聞いたのです。そのくらいだから父の危険が眼の前に涙ぐんだ。</p>\n<ol>\n<li>人間を愛し得る人、愛せずにはいられませんでした。</li>\n<li>私は何とも思いませんでした。</li>\n<li>するとKは足が向かなくなります。</li>\n</ol>"
  },
  "_id": "idea/example.md",
  "_raw": {
    "sourceFilePath": "idea/example.md",
    "sourceFileName": "example.md",
    "sourceFileDir": "idea",
    "contentType": "markdown",
    "flattenedPath": "idea/example"
  },
  "type": "Post",
}

これに ToC をつけたいというのが今回の記事の主旨です。

{
    "toc": "<div class=\"toc\"><div class=\"h2\">もっともその時の私の判断に縋り付こうとしたのです。<a aria-hidden=\"true\" tabindex=\"-1\" href=\"#もっともその時の私の判断に縋り付こうとしたのです\"><span class=\"icon icon-link\"></span></a></div><div class=\"h2\">しかし……私はこの質問の裏にそっと置かれた。<a aria-hidden=\"true\" tabindex=\"-1\" href=\"#しかし私はこの質問の裏にそっと置かれた\"><span class=\"icon icon-link\"></span></a></div></div>",
}

こういう感じ

<div class="toc">
  <div class="h2">もっともその時の私の判断に縋り付こうとしたのです。
    <a aria-hidden="true" tabindex="-1"
      href="#もっともその時の私の判断に縋り付こうとしたのです"
      >
      <span class="icon icon-link"></span>
    </a>
  </div>
  <div class="h2">しかし……私はこの質問の裏にそっと置かれた。
    <a aria-hidden="true" tabindex="-1"
      href="#しかし私はこの質問の裏にそっと置かれた">
      <span class="icon icon-link"></span>
    </a>
  </div>
</div>

プラグインで何とかする

  • Contentlayer は Markdown から AST への変換でremarkを、AST から html への変換でrehypeを使っており、それぞれのプラグインを使うことができます。
export default makeSource({
  contentDirPath: 'posts',
  documentTypes: [Post],
  markdown: {
    remarkPlugins: [/*ここにremarkプラグインを刺す*/],
    rehypePlugins: [/*ここにrehypeプラグインを刺す*/],
  },
});

remark-toc を使う

  • remark-tocは、目次を生成するための remark プラグインです。
    • remark プラグインということは、Markdown から AST への変換時に作用することになりますね。
  • これだけだとaタグが機能しないので、rehype-slugも入れておきます。
    • rehype-slug は、各h[1-6]タグにidを割り当ててくれるプラグインです。
npm install remark-toc rehype-slug
  • このように刺すと、# 目次の下に ToC を生成します。
import remarkToc from 'remark-toc';
import rehypeSlug from 'rehype-slug';

export default makeSource({
  contentDirPath: 'posts',
  documentTypes: [Post],
  markdown: {
    remarkPlugins: [[ remarkToc, { heading: '目次' } ]],
    rehypePlugins: [ rehypeSlug ] },
});
  • これが
## 目次

## Content1

## Content2
  • remark の処理中にこうなり
## 目次

- [Content1](#content1)
- [Content2](#content2)

## Content1

## Content2
  • 最終的にこうなります
<h2>目次</h2>
<ul>
  <li><a href="#content1">Content1</a></li>
  <li><a href="#content2">Content2</a></li>
</ul>
<h2 id="content1">Content1</h2>
<h2 id="content2">Content2</h2>

良し悪し

  • 目次の位置を Markdown の記載で制御できるのは、よい。
  • Markdown 側にheadingで指定した文字列でヘッダーを書いておかないといけないのは、よくはない。
    • 目次をつけたりつけなかったりしたい場合には、よい。
  • 本文と同じスタイルが当たるのは、どちらともいえない。

総じて、ToC を本文に紛れ込ませる用途にはよいですが、個人的には ToC をサイドバー的に表示したいので、ちょっと用途には合わないかなと。

rehype-toc を使う

  • rehype-tocは、目次を生成するための rehype プラグインです。
    • rehype プラグインということは、AST から html への変換時に作用することになります。
  • これもrehype-slugと一緒に使います。
npm install remark-toc rehype-slug
  • このように刺すと、navタグで ToC を生成します。
import rehypeToc from 'rehype-toc';
import rehypeSlug from 'rehype-slug';

export default makeSource({
  contentDirPath: 'posts',
  documentTypes: [Post],
  markdown: {
    rehypePlugins: [
      rehypeSlug,
      [rehypeToc, { headings: 'h2'} ]
    ]
  },
});
  • これが
## 目次

## Content1

## Content2
  • こうなります
<nav class="toc">
  <ol class="toc-level toc-level-1">
    <li class="toc-item toc-item-h2">
      <a class="toc-link toc-link-h2" href="#目次">目次</a>
    </li>
    <li class="toc-item toc-item-h2">
      <a class="toc-link toc-link-h2" href="#content1">Content1</a>
    </li>
    <li class="toc-item toc-item-h2">
      <a class="toc-link toc-link-h2" href="#content2">Content2</a>
    </li>
  </ol>
</nav>
<h2 id="目次">目次</h2>
<h2 id="content1">Content1</h2>
<h2 id="content2">Content2</h2>

良し悪し

  • navタグにclass付きなのでスタイルを当てやすいのは、よい
  • 本文側にはタグがないため、カラムレイアウトにする場合は、よくはない。
  • かならず本文の前に挿入されるのは、どちらともいえない。

おおむねこれでいいと思うのですが、カラムレイアウトにしたかったし、本文とは切り離してレンダリングしたかったので、完全に用途にマッチしたとは言い難い。

computedFieldsで何とかする

  • Contentlayer はドキュメントの型を指定することができますが、その中に Markdown 本体から取得するものとは別に、計算列(?)を設けることができます。

  • つまりこのようにして html からぶっこ抜くことができれば、tocに格納して本文とは完全に独立してレンダリングできます。

export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: `**/!(*_draft).md`,
  fields: {
    title: { type: 'string', required: true },
    description: { type: 'string' },
    date: { type: 'date', required: true },
    emoji: { type: 'string', required: true },
    tags: { type: 'list', of: { type: 'string' } },
  },
  // これ
  computedFields: {
    toc: {
      type: 'string',
      resolve: (post) => generateTableOfContent(post.body.html),
    },
  },
}));
  • こんな感じで、正規表現で強引に抜きます。
const generateTableOfContent = (html: string) => {
  const headings = html.matchAll(/<(h[1-6])\sid=\".*?\">(.*?)<\/h/g);
  const table = [...headings].map(
    (heading) => `<div class="${heading[1]}">${heading[2]}</div>`
  );
  return `<div class="toc">${table.join('')}</div>`;
};
  • すると、このように目次が生成されます。
<div class="toc">
  <div class="h2">目次</div>
  <div class="h2">Content1</div>
  <div class="h2">Content2</div>
</div>

良し悪し

  • 本文の html とは完全に独立して取得できるのは、よい。
    • スタイルもどうにでもあてられるので、よい。
  • <h[1-6]>タグの中身がそのまま抜けるのは、どちらともいえない。
    • 例えば、## [Next.js](https://nextjs.org/)を使うのような見出しは、以下のようになるので、外部リンク等すると目次からも飛んでしまう。
<div class="h2"><a href="https://nextjs.org/">Next.js</a>を使う</div>
  • 個人的には、リンクごと抜けることの懸念よりも、独立して取得できるほうがありがたいので、これを採用しています。

rehype-autolink-headingsでリンクをつける

  • このままでは目次から本文に飛べないので、ヘッダーが自動でリンクになるようにします。
  • rehype-autolink-headingsは、見出しに自動でアンカーをつけてくれます。
npm install rehype-autolink-headings
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug';

export default makeSource({
  contentDirPath: 'posts',
  documentTypes: [Post],
  markdown: {
    rehypePlugins: [
      rehypeSlug,
      [rehypeAutolinkHeadings, { behavior: 'append' }],
    ]
  },
});
  • すると、本文はこうなります。
    • behaviorappendを指定しているので、文字の後ろにaタグがきます。
      • デフォルトはpreppendで、文字の前にaタグが来ます。
      • wrapにすると、文字列全体がリンクになります。
      • afterbeforeにすると、hタグの外に、aタグが配置されます。
<h2 id="目次">目次
  <a aria-hidden="true" tabindex="-1" href="#目次">
    <span class="icon icon-link"></span>
  </a>
</h2>
<h2 id="content1">Content1
  <a aria-hidden="true" tabindex="-1" href="#content1">
    <span class="icon icon-link"></span>
  </a>
</h2>
<h2 id="content2">Content2
  <a aria-hidden="true" tabindex="-1" href="#content2">
  <span class="icon icon-link"></span>
  </a>
</h2>
  • ということは、tocも引きずられてこうなります。
<div class="toc">
   <div class="h2">目次
    <a aria-hidden="true" tabindex="-1" href="#目次">
      <span class="icon icon-link"></span>
    </a>
   </div>
   <div class="h2">Content1
    <a aria-hidden="true" tabindex="-1" href="#content1">
      <span class="icon icon-link"></span>
    </a>
   </div>
   <div class="h2">Content2
    <a aria-hidden="true" tabindex="-1" href="#content2">
      <span class="icon icon-link"></span>
    </a>
   </div>
</div>
  • spanタグが空なので、別途スタイルで差し込みます。
  .icon {
    @apply inline-block before:content-['🔗'];
  }
  • もしくは、contentオプションで差し込む内容を指定することもできます。
    • 例えば以下のようにすると、aタグの子要素は<span>🔗</span>になります。
  [
    rehypeAutolinkHeadings,
    {
      behavior: 'append',
      content: {
        type: 'element',
        tagName: 'span',
        properties: {},
        children: [{ type: 'text', value: '🔗' }],
      },
    },
  ],

カスタムタグを追加する

  • Markdown では表現しきれない場合、html を書くことになりますが、Qiita でいうところの

これとか

これ

が簡単に使えると、ブログとしての表現力がグッと増しますよね。

remark-directiveremark-directive-rehypeでカスタムタグを使う

  • remark-directiveはコロン(:)を使ったカスタムディレクティブを解釈できるようにする remark プラグインです。
  • remark-directive-rehyperemark-directive が解釈したカスタムディレクティブをremark-rehypeに橋渡ししてくれるプラグインです。
    • remark-rehypeは Contentlayer が中で使っています。mdastからhastへの変換を担っています。
npm install remark-directive remark-directive-rehype
  • なお、remark-directive-rehypeを使うと以下の型エラーになる場合があるのですが、ちょっと調べた限りでよくわからなかったので、とりあえず//@ts-expect-errorで潰しています(敗北)。
    • 動きはします。(よくはない)
"型 'Plugin' を型 'Pluggable<any[]>' に割り当てることはできません。
import remarkDirective from 'remark-directive'; // 追加
import remarkDirectiveRehype from 'remark-directive-rehype'; // 追加
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug';

export default makeSource({
  contentDirPath: 'posts',
  documentTypes: [Post],
  markdown: {
    remarkPlugins: [
      remarkDirective, // 追加
      //@ts-expect-error
      remarkDirectiveRehype, // 追加
    ],
    rehypePlugins: [
      rehypeSlug,
      [rehypeAutolinkHeadings, { behavior: 'append' }],
    ]
  },
});
  • これが
## Content1

:tag-warning[注意タグ]

## Content2

:::note-error
エラーノート
:::
  • こうなります
<h2 id="content1">Content1
  <a aria-hidden="true" tabindex="-1" href="#content1">
    <span class="icon icon-link"></span>
  </a>
</h2>
<p><tag-warning>注意タグ</tag-warning></p>
<h2 id="content2">Content2
    <a aria-hidden="true" tabindex="-1" href="#content2">
    <span class="icon icon-link"></span>
  </a>
</h2>
<note-error>
	<p>error / エラー</p>
</note-error>
  • これらは html タグではないのですが、ブラウザがいい感じに解釈して表示してくれます。
    • 甘えたくない場合は、カスタム要素を定義しておきます。
class TagWaning extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    const wrapper = document.createElement('span');
    wrapper.innerHTML = '<slot></slot>';
    wrapper.setAttribute('class', 'tag-warning');
    this.shadowRoot.append(wrapper);
  }
}
customElements.define('tag-warning', TagWaning);
class NoteError extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    const wrapper = document.createElement('div');
    wrapper.innerHTML = '<slot></slot>';
    wrapper.setAttribute('class', 'note-error');
    this.shadowRoot.append(wrapper);
  }
}
customElements.define('note-error', NoteError);

GitHub Flavored Markdown

  • 我々プログラマーは Markdown と称して息をするように GFM (のようなもの)を書いており、~~で打ち消し線が書けないと頭がおかしくなります。
    • 安心してください。remark-gfmがあります。
npm install remark-gfm
import remarkGfm from 'remark-gfm'; // 追加
import remarkDirective from 'remark-directive';
import remarkDirectiveRehype from 'remark-directive-rehype';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug';

export default makeSource({
  contentDirPath: 'posts',
  documentTypes: [Post],
  markdown: {
    remarkPlugins: [
      remarkDirective,
      //@ts-expect-error
      remarkDirectiveRehype,
      remarkGfm, // 追加
    ],
    rehypePlugins: [
      rehypeSlug,
      [rehypeAutolinkHeadings, { behavior: 'append' }],
    ]
  },
});
  • めんどくさくなってきたので例は割愛しますが、これで~~が打ち消し線になり、表も書けるようになります。

コードブロック

  • プログラミングブログであるならば、コードブロックはきれいに表示したいですよね。
    • rehype-pretty-codeできれいにしましょう。
    • rehype-pretty-codeShikiを使ってコードブロックを装飾してくれます。
npm install rehype-pretty-code shiki
import remarkGfm from 'remark-gfm';
import remarkDirective from 'remark-directive';
import remarkDirectiveRehype from 'remark-directive-rehype';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug';
import rehypePrettyCode from 'rehype-pretty-code'; // 追加

export default makeSource({
  contentDirPath: 'posts',
  documentTypes: [Post],
  markdown: {
    remarkPlugins: [
      remarkDirective,
      remarkDirectiveRehype,
      remarkGfm,
    ],
    rehypePlugins: [
      rehypeSlug,
      [rehypeAutolinkHeadings, { behavior: 'append' }],
      [rehypePrettyCode, { theme: 'rose-pine-moon' }], // 追加
    ]
  },
});
  • これでコードブロックにスタイルが当たるようになります。
    • テーマは Shiki のプリセットから選べます。参考

終わりに

  • ずいぶん長い記事になってしまいました。
    • remark/rehypeについては、掘り下げ甲斐がありそうなので、今後の勉強テーマの一つにしようと思っています。
  • スクショを貼るのも大変なので、最終的な表示は、個人ブログで見てもらえると嬉しいです。

参考

2
0
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
2
0