序
- Next.jsとContentlayerを使って、個人のMarkdown ブログを作りました。
- 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>
プラグインで何とかする
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' }],
]
},
});
- すると、本文はこうなります。
-
behavior
にappend
を指定しているので、文字の後ろにa
タグがきます。- デフォルトは
preppend
で、文字の前にa
タグが来ます。 -
wrap
にすると、文字列全体がリンクになります。 -
after
、before
にすると、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-directive
とremark-directive-rehype
でカスタムタグを使う
-
remark-directive
はコロン(:
)を使ったカスタムディレクティブを解釈できるようにする remark プラグインです。 -
remark-directive-rehype
はremark-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-code
はShikiを使ってコードブロックを装飾してくれます。
-
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については、掘り下げ甲斐がありそうなので、今後の勉強テーマの一つにしようと思っています。
- スクショを貼るのも大変なので、最終的な表示は、個人ブログで見てもらえると嬉しいです。
参考
- Qiita - Next.js のための Remark / Rehype 入門