代替バックエンド

「バックエンド」は、本の出力を生成する際にmdbookが呼び出すプログラムです。本と設定情報をJSONで表したデータが、stdinを通じて渡されます。バックエンドは、この情報を受け取ると、任意の処理を行えます。

バックエンドの使い方について、詳しくはレンダラーの設定を参照してください。

コミュニティは、いくつかのバックエンドを開発しています。利用できるバックエンドの一覧は、WikiのThird Party Pluginsページを参照してください。

準備する

このページでは、単語数を数える簡単なプログラムを例に、独自の代替バックエンドを作る手順を説明します。Rustで書きますが、PythonやRubyなどを使っても構いません。

最初に、バイナリーのプログラムを新しく作り、mdbook-rendererを依存関係へ追加します。

$ cargo new --bin mdbook-wordcount
$ cd mdbook-wordcount
$ cargo add mdbook-renderer

mdbook-wordcountプラグインが呼び出されると、mdbookはプラグインのstdinへ、JSON形式のRenderContextを送ります。読み込みに便利なコンストラクターRenderContext::from_json()で、RenderContextを読み込めます。

バックエンドが本を読み込むために必要な定型コードは、これだけです。

// src/main.rs
use std::io;
use mdbook_renderer::RenderContext;

fn main() {
    let mut stdin = io::stdin();
    let ctx = RenderContext::from_json(&mut stdin).unwrap();
}

注:RenderContextにはversionフィールドがあります。これによって、バックエンドは呼び出し元のmdbookのバージョンと互換性があるかを判断できます。このversionは、mdbookのCargo.tomlにある、対応するフィールドから直接取得されます。

バックエンドでは、semverクレートでこのフィールドを確認し、互換性に問題がありそうなら警告を出すことを推奨します。

本を調べる

バックエンドに本のコピーを取り込めたので、各章の単語数を数えましょう。

RenderContextはBookフィールド(book)を持ちます。Bookには、Bookのすべての項目を順にたどるBook::iter()メソッドがあるため、この工程も最初と同じくらい簡単です。


fn main() {
    let mut stdin = io::stdin();
    let ctx = RenderContext::from_json(&mut stdin).unwrap();

    for item in ctx.book.iter() {
        if let BookItem::Chapter(ref ch) = *item {
            let num_words = count_words(ch);
            println!("{}: {}", ch.name, num_words);
        }
    }
}

fn count_words(ch: &Chapter) -> usize {
    ch.content.split_whitespace().count()
}

バックエンドを有効にする

基本部分が動くようになったので、実際に使ってみましょう。まず、プログラムをインストールします。

$ cargo install --path .

次に、単語を数えたい本のディレクトリへcdで移動し、book.tomlファイルを更新します。

  [book]
  title = "mdBook Documentation"
  description = "Create book from markdown files. Like Gitbook but implemented in Rust"
  authors = ["Mathieu David", "Michael-F-Bryan"]

+ [output.html]

+ [output.wordcount]

本をメモリーへ読み込む際、mdbookはbook.tomlのすべてのoutput.*テーブルを調べ、使うバックエンドを判断します。何も指定していなければ、既定のHTMLレンダラーを使います。

つまり、独自のバックエンドを追加したい場合は、HTMLバックエンドも追加する必要があります。そのテーブルの内容は、空のままでも構いません。

あとは通常どおり本をビルドすれば、すべてそのまま動くはずです。

$ mdbook build
...
2018-01-16 07:31:15 [INFO] (mdbook::renderer): Invoking the "mdbook-wordcount" renderer
mdBook: 126
Command Line Tool: 224
init: 283
build: 145
watch: 146
serve: 292
test: 139
Format: 30
SUMMARY.md: 259
Configuration: 784
Theme: 304
index.hbs: 447
Syntax highlighting: 314
MathJax Support: 153
Rust code specific features: 148
For Developers: 788
Alternative Backends: 710
Contributors: 85

wordcountバックエンドの完全な名前やパスを指定する必要がなかったのは、mdbookが命名規則からプログラム名を推測するためです。fooバックエンドの実行ファイルは通常mdbook-fooという名前で、book.tomlの[output.foo]に対応します。コマンドライン引数が必要だったり、インタープリターで実行するスクリプトだったりする場合など、mdbookへ呼び出すコマンドを明示するには、commandフィールドを使えます。

  [book]
  title = "mdBook Documentation"
  description = "Create book from markdown files. Like Gitbook but implemented in Rust"
  authors = ["Mathieu David", "Michael-F-Bryan"]

  [output.html]

  [output.wordcount]
+ command = "python /path/to/wordcount.py"

設定

特定の章(生成された文章やコードなど)の単語数を数えたくない場合を考えます。標準的な方法は、通常のbook.toml設定ファイルの[output.foo]テーブルへ項目を追加することです。

Configは、おおむね入れ子のハッシュマップとして扱えます。get()などのメソッドで設定内容へアクセスできます。便利なget_deserialized()メソッドは、値を取得して任意の型Tへ自動でデシリアライズします。

実装するために、独自のシリアライズ可能なWordcountConfig構造体を作り、このバックエンドのすべての設定をまとめます。

まず、Cargo.tomlへserdeとserde_deriveを追加します。

$ cargo add serde serde_derive

続いて、設定の構造体を作れます。

#![allow(unused)]
fn main() {
use serde_derive::{Serialize, Deserialize};

...

#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "kebab-case")]
pub struct WordcountConfig {
  pub ignores: Vec<String>,
}
}

あとは、RenderContextからWordcountConfigをデシリアライズし、除外対象の章を飛ばすためのチェックを追加します。

  fn main() {
      let mut stdin = io::stdin();
      let ctx = RenderContext::from_json(&mut stdin).unwrap();
+     let cfg: WordcountConfig = ctx.config
+         .get_deserialized("output.wordcount")
+         .unwrap_or_default();

      for item in ctx.book.iter() {
          if let BookItem::Chapter(ref ch) = *item {
+             if cfg.ignores.contains(&ch.name) {
+                 continue;
+             }
+
              let num_words = count_words(ch);
              println!("{}: {}", ch.name, num_words);
          }
      }
  }

出力と失敗の通知

本のビルド時に、単語数をターミナルへ表示するのも便利ですが、ファイルへ出力するとよい場合もあります。mdbookは、RenderContextのdestinationフィールドで、生成した出力を置く場所をバックエンドへ伝えます。

+ use std::fs::{self, File};
+ use std::io::{self, Write};
- use std::io;
  use mdbook::renderer::RenderContext;
  use mdbook::book::{BookItem, Chapter};

  fn main() {
    ...

+     let _ = fs::create_dir_all(&ctx.destination);
+     let mut f = File::create(ctx.destination.join("wordcounts.txt")).unwrap();
+
      for item in ctx.book.iter() {
          if let BookItem::Chapter(ref ch) = *item {
              ...

              let num_words = count_words(ch);
              println!("{}: {}", ch.name, num_words);
+             writeln!(f, "{}: {}", ch.name, num_words).unwrap();
          }
      }
  }

注:出力先ディレクトリが存在することや、空であることは保証されません(バックエンドがキャッシュを使えるように、mdbookは以前の内容を残す場合があります)。そのため、fs::create_dir_all()で作成しておくとよいでしょう。

出力先ディレクトリがすでに存在していても、空だとは考えないでください。バックエンドが前回の結果をキャッシュできるように、mdbookは古い内容を残す場合があります。

本の処理中には、エラーが発生する可能性があります(ここまで書いた多くのunwrap()も、その例です)。mdbookは、0以外の終了コードを、出力生成の失敗として解釈します。

たとえば、すべての章の単語数が偶数であることを確認し、奇数ならエラーにする場合は、次のように書けます。

+ use std::process;
  ...

  fn main() {
      ...

      for item in ctx.book.iter() {
          if let BookItem::Chapter(ref ch) = *item {
              ...

              let num_words = count_words(ch);
              println!("{}: {}", ch.name, num_words);
              writeln!(f, "{}: {}", ch.name, num_words).unwrap();

+             if cfg.deny_odds && num_words % 2 == 1 {
+               eprintln!("{} has an odd number of words!", ch.name);
+               process::exit(1);
+             }
          }
      }
  }

  #[derive(Debug, Default, Serialize, Deserialize)]
  #[serde(default, rename_all = "kebab-case")]
  pub struct WordcountConfig {
      pub ignores: Vec<String>,
+     pub deny_odds: bool,
  }

バックエンドを再インストールし、本をビルドすると、次のようになります。

$ cargo install --path . --force
$ mdbook build /path/to/book
...
2018-01-16 21:21:39 [INFO] (mdbook::renderer): Invoking the "wordcount" renderer
mdBook: 126
Command Line Tool: 224
init: 283
init has an odd number of words!
2018-01-16 21:21:39 [ERROR] (mdbook::renderer): Renderer exited with non-zero return code.
2018-01-16 21:21:39 [ERROR] (mdbook::utils): Error: Rendering failed
2018-01-16 21:21:39 [ERROR] (mdbook::utils):    Caused By: The "mdbook-wordcount" renderer failed

気付いたかもしれませんが、プラグインの子プロセスからの出力は、すぐにユーザーへ渡されます。プラグインは「沈黙の原則」に従い、生成エラーや警告など、必要な場合だけ出力することを推奨します。

すべての環境変数はバックエンドへ引き継がれるため、通常どおりMDBOOK_LOGでログの詳しさを制御できます。

おわりに

説明用の例ではありますが、mdbookの代替バックエンドを作る方法を示せたと思います。不足している点があれば、ユーザーガイドを改善できるようにIssueトラッカーへ投稿してください。

章の冒頭で紹介した既存のバックエンドは、実際の作り方のよい例になります。ソースコードを読んだり、質問したりしてみてください。