diff options
Diffstat (limited to 'blog/2024/01/26/docusaurus-admonition-heading-toc/index.mdx')
-rw-r--r-- | blog/2024/01/26/docusaurus-admonition-heading-toc/index.mdx | 523 |
1 files changed, 523 insertions, 0 deletions
diff --git a/blog/2024/01/26/docusaurus-admonition-heading-toc/index.mdx b/blog/2024/01/26/docusaurus-admonition-heading-toc/index.mdx new file mode 100644 index 00000000..3ef3edd8 --- /dev/null +++ b/blog/2024/01/26/docusaurus-admonition-heading-toc/index.mdx @@ -0,0 +1,523 @@ +--- +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 #### テスト<sup>123</sup>テスト<u>下線</u> + +``` + +タイトル部のmarkdownは次の通りです。 + +#### テスト<sup>123</sup>テスト<u>下線</u> + +``` + +::: + + +## カスタマイズに利用した機能 + +カスタマイズは次の機能を活用しました。 + +- [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)の中にはもう一つ[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を操作できますが、データ構造が違うため、目的に応じて選択することになるのかなと思います。 + +#### Docusaurusにおけるプラグインの実行タイミング + +Docusaurusでこれらのプラグインを利用するためにはdocusaurus.config.jsonに設定が必要です。[このページによると](https://docusaurus.io/docs/markdown-features/plugins)次の4種類の設定値にてプラグインを登録できます。 + +| | デフォルトプラグイン適用前 | デフォルトプラグイン適用後 | +| -------------------- | --------------------------- | --------------------------- | +| Remark<br />Markdown形式 | beforeDefaultRemarkPlugins | remarkPlugins | +| Rehype<br />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プラグインが実行されることになります。 + +```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 + }, + // ... + }), + ]], + // ... +} +``` + +### 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 ( + <div + className={clsx( + ThemeClassNames.common.admonition, + ThemeClassNames.common.admonitionType(type), + styles.admonition, + className, + )}> + {children} + </div> + ); +} +// 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 ( + <div className={styles.admonitionHeading}> + <span className={styles.admonitionIcon}>{icon}</span> + {(() => { + if (depth == 3) { + return( + <h3 + id={id} + className={classNames} + > + {trimmedTitle} + </h3> + ) + } else if (depth == 4) { + return( + <h4 + id={id} + className={classNames} + > + {trimmedTitle} + </h4> + ) + } else if (depth == 5) { + return( + <h5 + id={id} + className={classNames} + > + {trimmedTitle} + </h5> + ) + } else if (depth == 6) { + return( + <h6 + id={id} + className={classNames} + > + {trimmedTitle} + </h6> + ) + } else { + return( + <> + {trimmedTitle} + </> + ) + } + })()} + </div> + ); +} +// highlight-end +function AdmonitionContent({children}) { + return children ? ( + <div className={styles.admonitionContent}>{children}</div> + ) : null; +} +export default function AdmonitionLayout(props) { + // highlight-next-line + const {type, icon, title, children, className, id} = props; + return ( + <AdmonitionContainer type={type} className={className}> + // highlight-next-line + <AdmonitionHeading title={title} icon={icon} id={id} /> + <AdmonitionContent>{children}</AdmonitionContent> + </AdmonitionContainer> + ); +} +``` + +上記を設定後、npm start等の再起動が必要です。 + +以上です。
\ No newline at end of file |