--- title: Docusaurusの注意書きや警告文のタイトルを見出しにして、目次にも乗せる方法 description: Docusaurus(V3.1)では、注意書きや警告文(Admonition)は見出し(Heading)にはならず目次にも掲載されないので、Remark/RehypeのプラグインとSwizzleを活用して実現しました。 authors: yohei tags: [技術, docusaurus, v3.1] hide_table_of_contents: false --- ## Docusaurus🦖 1ヵ月弱使いましたがこの[Docusaurus](https://docusaurus.io/)(ドキュサウルス)は数あるCMSの中でも秀逸です。 文書作成と管理が容易で、拡張の自由度も非常に高く、完全なオープンソース。 議員活動に重要な「**資料を作成しまとめて公開するツール**」として現状の最適解と感じます。 {/* truncate */} Facebookが母体なのでいろいろと気になるところですが、[React](https://ja.legacy.reactjs.org/)を初めとして有益なソフトウェアを完全なオープンソースとして提供してくれていることは純粋にありがたいと感じます。 ## Admonitionのタイトルが見出しにならない さてDocusaurusには[Admonition](https://docusaurus.io/docs/markdown-features/admonitions)(注意書きや警告文)を容易にMarkdownで書く方法が用意されています。 たとえばinfoなら、次のようにMarkdownで書けば、 ```mdx title="Admonitionの記法(mdもしくはmdxに記載)" :::info[infoの例] ここに文章を書く ::: ``` 次のようにHTMLで表示されます。 :::info infoの例 ここに文章を書く ::: しかし(DocusaurusV3.1)でこのAdmonitionのタイトルは見出し([Heading](https://docusaurus.io/docs/markdown-features/toc))にならず、目次([TOC](https://docusaurus.io/docs/markdown-features/toc))にも乗りません。上記例なら「INFOの例」がTOCに表示されません。 次の図からも分かっていただけるかと思います。 ![Admonitionのタイトルが見出しにならない](admonition-no-toc.png) 些細なことのようにも思えますが、Docusaurusを書籍のように扱うには結構気になるところ。 なお[以前はAdmonitionのタイトルはH5要素になっていた](https://github.com/elviswolcott/remark-admonitions/issues/26)ようですが、深さ(H1~H5のレベル)を決め打ちするのは好ましくないということから(?)、今はH5要素ではありません。 次のように本文中に見出しを書く方法もありますが ```mdx title="Admonitionの記法(mdもしくはmdxに記載)" :::info #### テスト ~文章~ ::: ``` 見た目がイマイチになります。 :::info #### テスト ~文章~ ::: [ほかのユーザーからの要望](https://github.com/facebook/docusaurus/discussions/7146)も上がっており、私も少し不便に感じていたので、次の仕様になるようカスタマイズしましたのでその方法を解説します。 ## カスタマイズ後はどうなるか 後述のカスタマイズをすると、Admonitionのタイトル部に(通常の見出しMarkdownと同様に)**#を冒頭に2個以上入れる** ことで見出しになります。またTOCにも反映されます。#を2個以上としているのは、H1をAdmonitionには使わないはずのため。#を入れない場合は見出しにならず、TOCにも反映されません。 ### タイトル冒頭に#を入れた場合 ```mdx title="Admonitionの記法(mdもしくはmdxに記載)" :::info[#### 見出しになりTOCに反映されるタイトルの例] #が4つ分のためH4見出しになります。TOCにも反映されます。 ::: ``` ↓ :::info #### 見出しになり、TOCに反映されるタイトルの例 #が4つ分のためH4見出しになります。TOCにも反映されます。 ::: このブログでもTOCに表示されています。 なおマウスカーソルを乗せた際にハッシュリンク(#)が表示されるようにするにはCSSの設定が必要です。 ### タイトル冒頭に#を入れない場合 ```mdx title="Admonitionの記法(mdもしくはmdxに記載)" :::info 見出しにならずTOCに反映されないタイトルの例 #がないため見出しにならず、TOCにも反映されません。 ::: ``` ↓ :::info #### 見出しにならず、TOCに反映されないタイトルの例 #がないため見出しになりません。 ::: ### タイトルにHTMLを入れることも可能 なおタイトル部にHTMLを入れることもできます。TOCにも反映されます。 :::info #### テスト123テスト下線 ``` タイトル部のmarkdownは次の通りです。 #### テスト123テスト下線 ``` ::: ## カスタマイズに利用した機能 カスタマイズは次の機能を活用しました。 - [Remark/Rehypeのプラグイン](https://docusaurus.io/docs/markdown-features/plugins) - [Swizzling](https://docusaurus.io/docs/swizzling) ### RemarkとRehypeについて RemarkとRehypeは、MarkdownをHTMLに変換するプロセスにおいて、AST(抽象構文木・Abstract Syntax Tree)に作用するプラグインです。なお[ASTを操作するオープンソースのエコシステム](https://github.com/unifiedjs/unified)の中にはもう1つ[Retext](https://github.com/retextjs/retext)というプラグインもありますが、Docusaurusには実装されていないようです。 ```md title="markdownからHTMLへ変換処理の流れ" | ........................ process ........................... | | .......... parse ... | ... run ... | ... stringify ..........| +--------+ +----------+ Input ->- | Parser | ->- Syntax Tree ->- | Compiler | ->- Output +--------+ | +----------+ X | +--------------+ | Transformers | +--------------+ ``` 上図([Unified Overviewより](https://github.com/unifiedjs/unified?tab=readme-ov-file#overview))にTransformersとあるところがRemark/Rehypeの動作するところ。 RemarkはMarkdown形式で、RehypeはHTML形式でASTを扱います。どちらも同じようにASTを操作できますが、データ構造が違うため、目的に応じて選択することになるのかなと思います。 [こちらのサイト](https://vivliostyle.github.io/vivliostyle_doc/ja/vivliostyle-user-group-vol2/spring-raining/index.html)などが詳しいです。 #### Docusaurusにおけるプラグインの実行タイミング Docusaurusでこれらのプラグインを利用するためにはdocusaurus.config.jsonに設定が必要です。[このページによると](https://docusaurus.io/docs/markdown-features/plugins)次の4種類の設定値にてプラグインを登録できます。 | | デフォルトプラグイン適用前 | デフォルトプラグイン適用後 | | -------------------- | --------------------------- | --------------------------- | | Remark
Markdown形式 | beforeDefaultRemarkPlugins | remarkPlugins | | Rehype
HTML形式 | beforeDefaultRehypePlugins | rehypePlugins | MarkdownからHTMLへの変換処理のところで、Docusaurusは自前のプラグイン(デフォルトプラグイン)を使い「見出しにidをつける」「ASTからTOCを作成する」などの処理を行っています。そのため今回のように「Amonitionのタイトルを読んでTOCに反映する」ためには、デフォルトプラグイン適用前と適用後の両方のタイミングでの処理が必要になります。 ### Swizzlingについて Swizzlingは[こちら](https://docusaurus.io/docs/swizzling)に説明があるとおりの機能で、簡単に言うとReactのコンポーネントをカスタマイズできる機能です。 Swizzlingの設定をすると、Docusaurusがデフォルトのコンポーネントの代わりに自動的にカスタマイズしたコンポーネントを使用します。 今回は、デフォルトのAdmonitionにないID属性を持たせるためAdmonitionコンポーネントをカスタマイズしました。Swizzlingの設定をすることにより、デフォルトのAdmonitionの代わりにこのカスタムコンポーネントが使われるようにします。 ## 動作原理 TOCは「ASTに含まれているheading要素を単純に配列に入れている」だけですが、この処理はカスタマイズで上書きできません。そこで、カスタマイズできる処理だけでAdmonitionのタイトルをTOCに反映する方法として次を思いつき、実装しました。 1. docusaurusのデフォルトプラグインがTOCの処理を行うより前に、Admonitionのタイトル部を見出しとして新規作成し、Admonition要素の直前に追加する 1. docusaurusのデフォルトプラグインがTOCの処理を行い、Admonitionのタイトル部がTOCに入る。見出しにはidが付与される 1. デフォルトプラグインの処理が終了したら作成した見出しは不要になるので削除する。その際、削除する見出しと同じタイトルをもつAdmonition要素を探し、idを与える 1. AdmonitionコンポーネントでidをHTMLタグに付与する ## 実装 ### docusaurus.config.json まずdocusaurus.config.jsonにimportとplugin設定を記入します(ハイライト部)。 これでDocusaurusデフォルトプラグイン適用の前後にそれぞれ自作のRemark/Rehypeプラグインが実行されることになります。 blogなどを入れている場合は、そのプロパティにも記載します。 ```js title="docusaurus.config.json" // highlight-start import admonitionTitleToHeadingBeforeTOC from './src/remark/admonition-title-to-heading-before-toc.js'; import admonitionTitleToHeadingAfterTOC from './src/rehype/admonition-title-to-heading-after-toc.js'; // highlight-end export default { // ... presets: [ [ 'classic', /** @type {import('@docusaurus/preset-classic').Options} */ ({ docs: { // ... // highlight-start beforeDefaultRemarkPlugins: [admonitionTitleToHeadingBeforeTOC], rehypePlugins: [admonitionTitleToHeadingAfterTOC], // highlight-end }, blog: { // ... // highlight-start beforeDefaultRemarkPlugins: [admonitionTitleToHeadingBeforeTOC], rehypePlugins: [admonitionTitleToHeadingAfterTOC], // highlight-end }, // ... }), ]], // ... } ``` ### Remark/Rehypeプラグイン 次にプラグインを実装します。 docusaurusのsrcディレクトリ下にrehypeとremarkというディレクトリを作り、次のファイル名と内容で2つのプラグインを作ります。 ```js title="src/rehype/admonition-title-to-heading-before-toc.js" import {visit} from 'unist-util-visit'; const plugin = (options) => { const transformer = async (ast) => { let newBeginningText = ""; const visitor = ((node, index, parent) => { if (node.type === 'containerDirective') { // :::infoなどに続くタイトル冒頭Text部(冒頭#を含む(もしくは含まない)部分)を取得(:::info ##** ) // (タイトル全体にはHTML等が含まれる可能性があるため冒頭Text部だけ操作する、残りはシャロ―コピー) const beginningText = node.children[0].children[0].value; // タイトル冒頭Text部に#が2つ以上連続しているとき if(/^##/.test(beginningText)) { // タイトル冒頭部から#とそれに続く空白を削除 newBeginningText = beginningText.replace(/^#+/, '').trim(); // タイトル部冒頭だけ更新し、残りはシャロ―コピー // まずタイトル部全体をシャロ―コピー let titleNodes = [...node.children[0].children]; // 冒頭要素のvalueを更新(ほかはシャロ―コピー) const newTitleBeginningNode = { ...titleNodes[0], value: newBeginningText, } // タイトルノードの冒頭要素だけ更新(ほかはシャロ―コピー) const newTitleNodes = [ ...titleNodes ]; newTitleNodes[0] = newTitleBeginningNode; // visitしているcontainerDirectiveの前にheadingノードを追加 parent.children.splice(index, 0, { type: 'heading', depth: (beginningText.match(/^##+/) || [''])[0].length, // #の連続数がheadingの深さ children: newTitleNodes, }); // 次に検索するのはindexを2つ分飛ばしたノード return index + 2; } } }); visit(ast, 'containerDirective', visitor); }; return transformer; }; export default plugin; ``` ```js title="src/rehype/admonition-title-to-heading-after-toc.js" import {visit} from 'unist-util-visit'; const plugin = (options) => { const transformer = async (ast) => { let hId = null; let hContent = null; visit(ast, 'element', (node, index, parent) => { if (/^h[2-6]$/.test(node.tagName) && node.properties && node.properties.id) { // H要素(h2~h6)を見つけた場合 // IDとタイトルの冒頭Text部を取得する hId = node.properties.id; hContent = node.children ? node.children[0].value : node.children[0].children[0] ? node.children[0].children[0].value : ''; // 続くAdmonitionを探す(docはH要素とadmonitionが連続しているが // blogではなぜか改行要素{ type:'text', value:'\n' }が間に入っているので念のため隣接3要素を探す for (let i = index + 1; i < index + 4 && i < parent.children.length; i++) { if(parent.children[i] && parent.children[i].tagName === 'admonition') { // admonition(div)を見つけた場合 const admonitionNode = parent.children[i]; // admonitionタイトルの冒頭Text部分を取得(properties.titleもしくはchildren[0].children[0].value) const admonitionNodeTitle = admonitionNode.properties.title ? admonitionNode.properties.title : admonitionNode.children[0] && admonitionNode.children[0].children[0] ? admonitionNode.children[0].children[0].value : ''; if(/^##/.test(admonitionNodeTitle) && admonitionNodeTitle.replace(/^#+/, '').trim() === hContent.trim()) { // #で始まっていて、タイトル冒頭部が同じ場合 // divのidをHタグのidに設定 admonitionNode.properties.id = hId; // H要素を削除 parent.children.splice(index, 1); } } } } }); }; return transformer; }; export default plugin; ``` :::note Admonitionのツリー構造 参考までに、Remarkのプラグインから見るとAdmonitionのASTはたとえば次のようになっています。 ```javascript title="Admonitionのツリー構造(一例)" { type: 'containerDirective', name: 'info', attributes: {}, children: [ { type: 'paragraph', data: { directiveLabel: true }, children: [ { type: 'text', value: '#### info title もしHTML等が入ると(ここにaタグを入れると)', position: [Object] }, { type: 'mdxJsxTextElement', name: 'a', attributes: [], position: [Object], data: [Object], children: [Array] }, { type: 'text', value: 'このようにタイトル部が別々の要素として配列に入っている。', position: [Object] } ], position: { start: { line: 1347, column: 8, offset: 34053 }, end: { line: 1347, column: 55, offset: 34100 } } }, { type: 'paragraph', children: [Array], position: [Object] }, ... ], ... } ``` ::: ### Swizzling 次にSwizzlingです。 Docusaurusのsrc/themeディレクトリにAdmonitionというディレクトリを作り、次の一例のファイルを格納します。 なお[ここに説明がある通り](https://docusaurus.io/docs/swizzling#ejecting)SwizzlingにはEjectingとWrappingの方法があります。 WrappingではAdmonitionの内部までカスタマイズできないため「タイトル部分にidプロパティを付ける」といったことができません。そのためEjectingを使います。 :::note Ejectingを使う場合はバージョンアップによってデフォルトのコンポーネントと挙動が変わってくる可能性があるのでアップグレードの際は注意が必要です。 ::: #### Ejecting ```bash npm run swizzle @docusaurus/theme-classic Admonition -- --eject ``` Docusaurus V3.1では次のようなメッセージが出ますので、YESを選びます。 ```bash ? Do you really want to swizzle this unsafe internal component? » - Use arrow-keys. Return to submit. NO: cancel and stay safe > YES: I know what I am doing! ``` Ejectingをすると、実質的にnode_modulesの@docusaurus/theme-classic/lib/themeにあるコンポーネントがsrc/themeディレクトリにコピーされます。 あとはコピーされたコンポーネントをいじるだけです。 #### コードの変更 Layoutファイルを次のように変更するのみです。 ```js title="src/theme/Admonition/Layout/index.js" import React from 'react'; import clsx from 'clsx'; import {ThemeClassNames} from '@docusaurus/theme-common'; import styles from './styles.module.css'; // highlight-next-line import headingStyles from '@docusaurus/theme-classic/lib/theme/Heading/styles.module.css'; function AdmonitionContainer({type, className, children}) { return (
{children}
); } // highlight-start function AdmonitionHeading({icon, title, id}) { let depth = 0; let trimmedTitle = title; // titleにHTML等が含まれている場合は文字列ではなく配列になる if(typeof title === "string") { // 文字列冒頭の#の数を数える( depth = title.match ? (title.toString().match(/^#+/) || [''])[0].length : 0; // #を省いたタイトルを得る trimmedTitle = depth > 0 ? title.replace(/^#+/, '').trim() : title; } else if (typeof title[0] === "string") { depth = title[0].match ? (title[0].match(/^#+/) || [''])[0].length : 0; trimmedTitle = depth > 0 ? [title[0].replace(/^#+/, '').trim(), ...title.slice(1)] : title; } // スクロール位置調整のcss const classNames = clsx("anchor", "title", headingStyles.anchorWithStickyNavbar); // depthに応じて見出しタグをレンダー return (
{icon} {(() => { if (depth == 3) { return(

{trimmedTitle}

) } else if (depth == 4) { return(

{trimmedTitle}

) } else if (depth == 5) { return(
{trimmedTitle}
) } else if (depth == 6) { return(
{trimmedTitle}
) } else { return( <> {trimmedTitle} ) } })()}
); } // highlight-end function AdmonitionContent({children}) { return children ? (
{children}
) : null; } export default function AdmonitionLayout(props) { // highlight-next-line const {type, icon, title, children, className, id} = props; return ( // highlight-next-line {children} ); } ``` 上記を設定後、npm start等の再起動が必要です。 以上です。