✏️

mdBookのplaygroundを乗っ取る

2023/01/14に公開

はじめに

mdBook とは Rust 製のドキュメントビルダーです。 Markdown で書かれたドキュメントを HTML にレンダリングしてくれるもので、 Rust の公式・非公式ドキュメントで広く使われています。

この mdBook には playground という機能があります。例えば Rust の公式ドキュメントの Hello worldのページ 中段に以下のようなコードブロックがあります。

このコードブロックの右上にマウスカーソルを合わせると ▶ のボタンが現れるので、これを押すと

このようにプログラムを実行した結果が表示されました。
今回この playground 機能を改造して、 WebAssembly 化した自作の言語処理系の結果を表示できるようになったので、その方法を紹介します。

実際に動作するものは以下にあります。コード右上の▶ボタンを押してみてください。

https://dalance.github.io/veryl/book/03_code_examples/01_module.html

playground の仕組み

playground の機能は mdBook を実行して生成される book.js というファイルに実装されています。100行目付近にある run_rust_code 関数です。

    function run_rust_code(code_block) {
        var result_block = code_block.querySelector(".result");
        if (!result_block) {
            result_block = document.createElement('code');
            result_block.className = 'result hljs language-bash';

            code_block.append(result_block);
        }

        let text = playground_text(code_block);
        let classes = code_block.querySelector('code').classList;
        let edition = "2015";                                                                                                                                                                                                                                                                                                                                                                                 if(classes.contains("edition2018")) {
            edition = "2018";
        } else if(classes.contains("edition2021")) {
            edition = "2021";
        }
        var params = {
            version: "stable",
            optimize: "0",
            code: text,
            edition: edition
        };

        if (text.indexOf("#![feature") !== -1) {
            params.version = "nightly";
        }

        result_block.innerText = "Running...";

        fetch_with_timeout("https://play.rust-lang.org/evaluate.json", {
            headers: {
                'Content-Type': "application/json",
            },
            method: 'POST',
            mode: 'cors',
            body: JSON.stringify(params)
        })
        .then(response => response.json())
        .then(response => {
            if (response.result.trim() === '') {
                result_block.innerText = "No output";
                result_block.classList.add("result-no-output");
            } else {
                result_block.innerText = response.result;
                result_block.classList.remove("result-no-output");
            }
        })
        .catch(error => result_block.innerText = "Playground Communication: " + error.message);
    }

これはコードブロックの内容を Rust の playground を提供している https://play.rust-lang.org/ に投げてその結果を表示するようになっています。リクエスト生成・取得部分を削ると以下のような感じになって、コメントの部分に自分のやりたい処理を書けばいいことになります。

    function run_rust_code(code_block) {
        var result_block = code_block.querySelector(".result");
        if (!result_block) {
            result_block = document.createElement('code');
            result_block.className = 'result hljs language-bash';

            code_block.append(result_block);
        }

        let text = playground_text(code_block);
	
	// text に対してなにか処理
	
        result_block.innerText = text;
    }

WebAssembly の準備

WebAssembly の作り方は詳しくないので割愛します。とりあえず以下の通りにすれば問題ありませんでした。

https://developer.mozilla.org/ja/docs/WebAssembly/Rust_to_wasm

ここでは以下のような parse 関数をエクスポートしたとします。

#[wasm_bindgen]
extern {
    pub fn parse(s: &str) -> String;
}

WebAssembly のロード

WebAssembly をビルドすると [package name]_bg.wasm のようなバイナリ本体と [package name].js のような呼び出し用のスクリプト(以下 wasm.js とします)が生成されます。これをどうにかして mdBook が生成する HTML から読み込ませないといけません。

mdBook の生成する HTML は Handlebars というテンプレートエンジンを使ってテンプレートから生成しています。 mdBook にはテーマ変更機能があって、このテンプレートをユーザが指定することができます。

まず、

$ mdbook init --theme temp

すると temp ディレクトリにデフォルトのテーマファイルが生成されるので、それを変更して、自分の mdBook プロジェクトの theme ディレクトリにコピーすればOKです。

HTML のテンプレートは theme/index.hbs です。 book.js のあたりに追加すればいいでしょう。

        <script src="{{ path_to_root }}clipboard.min.js"></script>
        <script src="{{ path_to_root }}highlight.js"></script>
        <script src="{{ path_to_root }}book.js"></script>
        <script type="module">
            import init, {parse} from "./wasm.js";
            init().then(() => {});
            window.parse = parse;
        </script>

JavaScript はよくわかっていませんが、 wasm-pack が生成する wasm.js は新しいモジュール形式というもので、book.js は従来形式だそうです。モジュール形式の方は名前空間が分かれているので、そのままでは book.js から wasm.js 内の pasre 関数を呼ぶことはできません。
しょうがないので window.parse = parse; としてグローバルから見える位置に出してみました。
(このあたりはきっと正しいやり方があると思うのですが、調べてもよくわかりませんでした…)

book.js の改造

さきほどの book.js を改造します。このファイルも theme ディレクトリにあるのでそれを改造すればそのまま反映されます。単に WebAssembly の parse を呼ぶだけです。

    function run_rust_code(code_block) {
        var result_block = code_block.querySelector(".result");
        if (!result_block) {
            result_block = document.createElement('code');
            result_block.className = 'result hljs language-bash';

            code_block.append(result_block);
        }

        let text = playground_text(code_block);        
        result_block.innerText = window.parse(text);
    }

コードブロックの変換

さて、ここまでで mdBook の生成した HTML は WebAssembly をロードし、自作の run_rust_code を呼べるようになりました。しかし、まだ実行するための ▶ ボタンが出てきません。
playground が使用できる Rust ドキュメントの HTML を見ると以下のようになっています。

<pre><pre class="playground"><code class="language-rust">

すなわち class="playground" を付与すれば▶ボタンが出てきます。
mdBook ではカスタムバックエンドもサポートしています。従って、デフォルトの HTML バックエンドを改造して、 Rust 以外のコードブロックでも class="playground" を付与するようにすればいいということになります。

そうはいっても HTML バックエンドの動作はほとんどデフォルトと同じなので、わざわざ改造するのも面倒です。今回はちょっと手を抜いて、生成された HTML に後からパッチを当てることにします。

以下のようなシェルスクリプト playground.sh を用意して、hoge 言語のコードブロックに後から class="playground" を付与します。

playground.sh
#!/bin/sh
find . -name "*.html" | xargs sed -i "s/<pre><code class=\"language-hoge\">/<pre class=\"playground\"><code class=\"language-hoge\">/g"

これを手動で実行してもいいですが、 mdBook のカスタムバックエンドとして組み込むと便利です。それには book.toml に以下のように追加します。

book.toml
[output.html]

[output.playground]
command = "./playground.sh"

最初の [output.html] は HTML バックエンドを実行することを、 [output.playground] は playground という新しいバックエンドを実行することを示します。 command フィールドが与えられると mdBook はそのコマンドをバックエンドとして実行するので、先ほどの playground.sh を実行するようにします。このとき、 playground.sh の実行ディレクトリは book/playground となり、 HTML バックエンドが生成した HTML は book/html ディレクトリにあるため、 find する先は調整が必要です。

playground.sh
#!/bin/sh
find ../html -name "*.html" | xargs sed -i "s/<pre><code class=\"language-hoge\">/<pre class=\"playground\"><code class=\"language-hoge\">/g"

mdBook は [output.*] を書いた順にバックエンドを実行するので、 HTML を生成した後にパッチを当てることができます。

まとめ

これで WebAssembly 化した自作コードを mdBook の playground から実行できるようになりました。最終的なコード類は以下にあります。

https://github.com/dalance/veryl/tree/master/book

mdBook 側にいくつか機能追加すれば(例えば任意のコードブロックに強制的に playground を付与するオプションなど)もう少し簡単にできるようになるので、そのうち提案するかもしれません。

Discussion