📕

mdBookのプラグイン開発備忘録

2023/03/16に公開

mdBookのプラグインを開発した際の備忘録
プリプロセッサ開発の導入にお役立てください。

mdBookとは

Markdownを用いて文書を作成できるRust製のツールです。
例えばmdBookのドキュメントはmdBookで作成されています。

mdBookのプラグインについて

mdBookは個人でプラグインを作成して読み込ませることができます。
プラグインには「プリプロセッサ」と「代替バックエンド」があります。

本記事では「mdbook-embed」という、埋め込みコードを差し込みやすくするプリプロセッサ開発を基に執筆しています。

https://github.com/kumavale/mdbook-embed

プロジェクト作成

プリプロセッサでは、デフォルトでmdbook-から始まるコマンドを呼び出すため、クレート名もそれに倣います。
因みにmdbookのバージョンは v0.4.28 で開発しています。

$ cargo new mdbook-embed

最低限必要なものをコピペ

mdBookはプリプロセッサに対してJSONデータをstdinに渡します。
プリプロセッサはそれに対して実行したい変更を加えて、JSON形式でstdoutに返す必要があります。
その仕組みは公式が公開しているプリプロセッサの例から簡単に適応できます!

  • make_app(): コマンドライン引数の解析
  • handle_preprocessing(): カスタムプリプロセッサを呼び出す本体
  • handle_supports(): 指定されたレンダラーをサポートしているか

上記関数では引数にPreprecessorトレイトを取ります。
カスタムプリプロセッサ用に「Embed構造体」を定義して、これらも同時に実装します。

  • name(&self) -> &str
  • run(&self, ctx: &PreprocessorContext, book: Book) -> Result<Book>

その他、コマンド名等を適宜変更しています。

Cargo.toml
  [package]
  name = "mdbook-embed"
  version = "0.1.0"
  edition = "2021"

  # See more keys and their definitions at https://doc.rust- lang.org/cargo/reference/manifest.html

  [dependencies]
+ clap = "4.*"
+ mdbook = "0.4.*"
+ serde_json = "1.*"
main.rs
use clap::{Arg, ArgMatches, Command};
use mdbook::{
    book::Book,
    errors::Error,
    preprocess::{CmdPreprocessor, Preprocessor, PreprocessorContext},
};
use std::{io, process};

pub fn make_app() -> Command {
    Command::new("embed-preprocessor")
        .about("A mdbook preprocessor which import embed anything")
        .subcommand(
            Command::new("supports")
                .arg(Arg::new("renderer").required(true))
                .about("Check whether a renderer is supported by this preprocessor"),
        )
}

fn main() {
    let matches = make_app().get_matches();

    // Users will want to construct their own preprocessor here
    let preprocessor = Embed::new();

    if let Some(sub_args) = matches.subcommand_matches("supports") {
        handle_supports(&preprocessor, sub_args);
    } else if let Err(e) = handle_preprocessing(&preprocessor) {
        eprintln!("{}", e);
        process::exit(1);
    }
}

fn handle_preprocessing(pre: &dyn Preprocessor) -> Result<(), Error> {
    let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())?;

    if ctx.mdbook_version != mdbook::MDBOOK_VERSION {
        eprintln!(
            "Warning: The {} plugin was built against version {} of mdbook, \
             but we're being called from version {}",
            pre.name(),
            mdbook::MDBOOK_VERSION,
            ctx.mdbook_version
        );
    }

    let processed_book = pre.run(&ctx, book)?;
    serde_json::to_writer(io::stdout(), &processed_book)?;

    Ok(())
}

fn handle_supports(pre: &dyn Preprocessor, sub_args: &ArgMatches) -> ! {
    let renderer = sub_args
        .get_one::<String>("renderer")
        .expect("Required argument");
    let supported = pre.supports_renderer(renderer);

    // Signal whether the renderer is supported by exiting with 1 or 0.
    if supported {
        process::exit(0);
    } else {
        process::exit(1);
    }
}

struct Embed;

impl Embed {
    fn new() -> Embed {
        Embed
    }
}

impl Preprocessor for Embed {
    fn name(&self) -> &str {
        "embed-preprocessor"
    }

    fn run(&self, _ctx: &PreprocessorContext, book: Book) -> Result<Book, Error> {
        dbg!("mdbook-embed is running");
        Ok(book)
    }
}

ビルドして実行してみる

ローカルパスからインストールします。

$ cargo install --path .

テスト用のディレクトリを作り、mdbookの初期化をする。

$ mkdir test && cd test
$ mdbook init

book.tomlにプリプロセッサを呼び出す設定を追加。
例えば、[preprocessor.embed]と書くと、mdBookのビルド時にmdbook-embedを呼び出すようになります。

book.toml
  [book]
  authors = ["kumavale"]
  language = "en"
  multilingual = false
  src = "src"
  title = "mdbook-embed-test"

+ [preprocessor.embed]
$ mdbook build
20XX-01-23 12:34:56 [INFO] (mdbook::book): Book building has started
[src/main.rs:80] "mdbook-embed is running" = "mdbook-embed is running"
20XX-01-23 12:34:56 [INFO] (mdbook::book): Running the html backend

run()dbg!()を追加しておいたので、プリプロセッサが読み込まれていることが確認できます。
上手く動いてそうなので、引き続き処理を書いていきます。

プラグイン本体の実装

{{#embed <url>}}にマッチする文字列を正規表現で探し、<url>に入力されているものをYouTubeの埋め込みコードに変換します。
YouTubeのURLにマッチしなかったものは<a>タグを使用してリンクに変換することとします。

Cargo.toml
+ regex = "1"
main.rs
+ use regex::Regex;

-     fn run(&self, _ctx: &PreprocessorContext, book: Book) -> Result<Book, Error> {
-         dbg!("mdbook-embed is running");
+     fn run(&self, _ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
+         let embed_re = Regex::new(r".*\{\{\s*#embed\s*(?P<url>.*)\s*\}\}").unwrap();
+         let youtube_re = Regex::new(r".+youtube\.com.+v=(.*)").unwrap();
+         book.for_each_mut(|item| {
+             if let mdbook::book::BookItem::Chapter(chap) = item {
+                 chap.content = embed_re.replace_all(&chap.content, |caps: &regex::Captures| {
+                     let url = caps.name("url").unwrap().as_str().to_owned();
+                     if let Some(cap) = youtube_re.captures_iter(&url).next() {
+                         format!("<iframe width=\"560\" height=\"315\" src=\"https://www.youtube.com/embed/{}\"></iframe>", &cap[1])
+                     } else {
+                         format!("<a href=\"{url}\">{url}</a>")
+                     }
+                 }).to_string();
+             }
+         });
          Ok(book)
      }
test > src > chapter_1.md
# Chapter 1

{{#embed https://www.youtube.com/watch?v=d66B35sT1gQ}}
$ mdbook build --open

おわりに

このような流れで開発していきます。
mdBookのドキュメントを読んでいる時は難しそうな印象でしたが、実際に開発してみると以外と簡単だと感じました。

Discussion