Docusaurusの注意書きや警告文のタイトルを見出しにして、目次にも乗せる方法
Docusaurus🦖
1ヵ月弱使いましたがこのDocusaurus(ドキュサウルス)は数あるCMSの中でも秀逸です。
文書作成と管理が容易で、拡張の自由度も非常に高く、完全なオープンソース。
議員活動に重要な「資料を作成しまとめて公開するツール」として現状の最適解と感じます。
Facebookが母体なのでいろいろと気になるところですが、Reactを初めとして有益なソフトウェアを完全なオープンソースとして提供してくれていることは純粋にありがたいと感じます。
Admonitionのタイトルが見出しにならない
さてDocusaurusにはAdmonition(注意書きや警告文)を容易にMarkdownで書く方法が用意されています。
たとえばinfoなら、次のようにMarkdownで書けば、
:::info[infoの例]
ここに文章を書く
:::
次のようにHTMLで表示されます。
ここに文章を書く
しかし(DocusaurusV3.1)でこのAdmonitionのタイトルは見出し(Heading)にならず、目次(TOC)にも乗りません。上記例なら「INFOの例」がTOCに表示されません。
次の図からも分かっていただけるかと思います。

些細なことのようにも思えますが、Docusaurusを書籍のように扱うには結構気になるところ。
なお以前はAdmonitionのタイトルはH5要素になっていたようですが、深さ(H1~H5のレベル)を決め打ちするのは好ましくないということから(?)、今はH5要素ではありません。
次のように本文中に見出しを書く方法もありますが
:::info
#### テスト
~文章~
:::
見た目がイマイチになります。
テスト
~文章~
ほかのユーザーからの要望も上がっており、私も少し不便に感じていたので、次の仕様になるようカスタマイズしましたのでその方法を解説します。
カスタマイズ後はどうなるか
後述のカスタマイズをすると、Admonitionのタイトル部に(通常の見出しMarkdownと同様に)#を冒頭に2個以上入れる ことで見出しになります。またTOCにも反映されます。#を2個以上としているのは、H1をAdmonitionには使わないはずのため。#を入れない場合は見出しにならず、TOCにも反映されません。
タイトル冒頭に#を入れた場合
:::info[#### 見出しになりTOCに反映されるタイトルの例]
#が4つ分のためH4見出しになります。TOCにも反映されます。
:::
↓
見出しになり、TOCに反映されるタイトルの例
#が4つ分のためH4見出しになります。TOCにも反映されます。
このブログでもTOCに表示されています。
なおマウスカーソルを乗せた際にハッシュリンク(#)が表示されるようにするにはCSSの設定が必要です。
タイトル冒頭に#を入れない場合
:::info[見出しにならずTOCに反映されないタイトルの例]
#がないため見出しにならず、TOCにも反映されません。
:::
↓
見出しにならず、TOCに反映されないタイトルの例
#がないため見出しになりません。
タイトルにHTMLを入れることも可能
なおタイトル部にHTMLを入れることもできます。TOCにも反映されます。
テスト123テスト下線
タイトル部のmarkdownは次の通りです。
#### テスト<sup>123</sup>テスト<u>下線</u>
カスタマイズに利用した機能
カスタマイズは次の機能を活用しました。
RemarkとRehypeについて
RemarkとRehypeは、MarkdownをHTMLに変換するプロセスにおいて、AST(抽象構文木・Abstract Syntax Tree)に作用するプラグインです。なおASTを操作するオープンソースのエコシステムの中にはもう1つRetextというプラグインもありますが、Docusaurusには実装されていないようです。
| ........................ process ........................... |
| .......... parse ... | ... run ... | ... stringify ..........|
          +--------+                     +----------+
Input ->- | Parser | ->- Syntax Tree ->- | Compiler | ->- Output
          +--------+          |          +----------+
                              X
                              |
                       +--------------+
                       | Transformers |
                       +--------------+
上図(Unified Overviewより)にTransformersとあるところがRemark/Rehypeの動作するところ。
RemarkはMarkdown形式で、RehypeはHTML形式でASTを扱います。どちらも同じようにASTを操作できますが、データ構造が違うため、目的に応じて選択することになるのかなと思います。
こちらのサイトなどが詳しいです。
Docusaurusにおけるプラグインの実行タイミング
Docusaurusでこれらのプラグインを利用するためにはdocusaurus.config.jsonに設定が必要です。このページによると次の4種類の設定値にてプラグインを登録できます。
| デフォルトプラグイン適用前 | デフォルトプラグイン適用後 | |
|---|---|---|
| Remark Markdown形式 | beforeDefaultRemarkPlugins | remarkPlugins | 
| Rehype HTML形式 | beforeDefaultRehypePlugins | rehypePlugins | 
MarkdownからHTMLへの変換処理のところで、Docusaurusは自前のプラグイン(デフォルトプラグイン)を使い「見出しにidをつける」「ASTからTOCを作成する」などの処理を行っています。そのため今回のように「Amonitionのタイトルを読んでTOCに反映する」ためには、デフォルトプラグイン適用前と適用後の両方のタイミングでの処理が必要になります。
Swizzlingについて
Swizzlingはこちらに説明があるとおりの機能で、簡単に言うとReactのコンポーネントをカスタマイズできる機能です。
Swizzlingの設定をすると、Docusaurusがデフォルトのコンポーネントの代わりに自動的にカスタマイズしたコンポーネントを使用します。
今回は、デフォルトのAdmonitionにないID属性を持たせるためAdmonitionコンポーネントをカスタマイズしました。Swizzlingの設定をすることにより、デフォルトのAdmonitionの代わりにこのカスタムコンポーネントが使われるようにします。
動作原理
TOCは「ASTに含まれているheading要素を単純に配列に入れている」だけですが、この処理はカスタマイズで上書きできません。そこで、カスタマイズできる処理だけでAdmonitionのタイトルをTOCに反映する方法として次を思いつき、実装しました。
- docusaurusのデフォルトプラグインがTOCの処理を行うより前に、Admonitionのタイトル部を見出しとして新規作成し、Admonition要素の直前に追加する
- docusaurusのデフォルトプラグインがTOCの処理を行い、Admonitionのタイトル部がTOCに入る。見出しにはidが付与される
- デフォルトプラグインの処理が終了したら作成した見出しは不要になるので削除する。その際、削除する見出しと同じタイトルをもつAdmonition要素を探し、idを与える
- AdmonitionコンポーネントでidをHTMLタグに付与する
実装
docusaurus.config.json
まずdocusaurus.config.jsonにimportとplugin設定を記入します(ハイライト部)。
これでDocusaurusデフォルトプラグイン適用の前後にそれぞれ自作のRemark/Rehypeプラグインが実行されることになります。
blogなどを入れている場合は、そのプロパティにも記載します。
import admonitionTitleToHeadingBeforeTOC from './src/remark/admonition-title-to-heading-before-toc.js';
import admonitionTitleToHeadingAfterTOC from './src/rehype/admonition-title-to-heading-after-toc.js';
export default {
    // ...
    presets: [
    [
        'classic',
        /** @type {import('@docusaurus/preset-classic').Options} */
        ({
        docs: {
            // ...
            beforeDefaultRemarkPlugins: [admonitionTitleToHeadingBeforeTOC],
            rehypePlugins: [admonitionTitleToHeadingAfterTOC],
        },
        blog: {
            // ...
            beforeDefaultRemarkPlugins: [admonitionTitleToHeadingBeforeTOC],
            rehypePlugins: [admonitionTitleToHeadingAfterTOC],
        },
        // ...
        }),
    ]],
    // ...
}
Remark/Rehypeプラグイン
次にプラグインを実装します。
docusaurusのsrcディレクトリ下にrehypeとremarkというディレクトリを作り、次のファイル名と内容で2つのプラグインを作ります。
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;
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;
参考までに、Remarkのプラグインから見るとAdmonitionのASTはたとえば次のようになっています。
{
  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というディレクトリを作り、次の一例のファイルを格納します。
なおここに説明がある通りSwizzlingにはEjectingとWrappingの方法があります。
WrappingではAdmonitionの内部までカスタマイズできないため「タイトル部分にidプロパティを付ける」といったことができません。そのためEjectingを使います。
Ejectingを使う場合はバージョンアップによってデフォルトのコンポーネントと挙動が変わってくる可能性があるのでアップグレードの際は注意が必要です。
Ejecting
npm run swizzle @docusaurus/theme-classic Admonition -- --eject
Docusaurus V3.1では次のようなメッセージが出ますので、YESを選びます。
? 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ファイルを次のように変更するのみです。
import React from 'react';
import clsx from 'clsx';
import {ThemeClassNames} from '@docusaurus/theme-common';
import styles from './styles.module.css';
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>
  );
}
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>
  );
}
function AdmonitionContent({children}) {
  return children ? (
    <div className={styles.admonitionContent}>{children}</div>
  ) : null;
}
export default function AdmonitionLayout(props) {
  const {type, icon, title, children, className, id} = props;
  return (
    <AdmonitionContainer type={type} className={className}>
      <AdmonitionHeading title={title} icon={icon} id={id} />
      <AdmonitionContent>{children}</AdmonitionContent>
    </AdmonitionContainer>
  );
}
上記を設定後、npm start等の再起動が必要です。
以上です。
