Posted at

Rust で書く CommonMark-to-HTML の SSG、その 1

More than 1 year has passed since last update.


はじめに

最近 Rust で静的サイトジェネレータ (SSG) を書いているのですが、その途中で色々知ったことや、考えたことをここにまとめたいと思います。このまとめを書いている時点でのソースはここです。

ちなみに、この SSG の名前は writer2 と言います。なぜ 2 なのかというと、もともと CommonMark のパーサから作ろうと思って始めた writer というプロジェクトもあるのですが、さすがに時間がかかり過ぎるので、とりあえず便利な pulldown-cmark を使って簡単な SSG を作りました。これが writer2 です。

pulldown-cmark は、まだ完全に CommonMark 0.28 のスペックに達していません。やはり、cmark あたりの Rust 向けのバインディングとかがあれば、そっちを使いたいですね。


何がしたいのか

指定されたディレクトリの中のファイルのうち、CommonMark で書かれたファイルを HTML に変換し、その他のファイルはそのままコピーするという、ごく一般的な SSG を作りたいと思います。自分が SSG を使うとしたら、言語学、数学やコーディングについて書くことが多いので、Syntax highlighting とか、MathJax とかも将来は組み込んでみたいのですが、とりあえず今回は基本的な機能の実装について話したいと思います。

writer2 は、こんな風に使います。

usage: target\debug\writer2.exe INPUT-DIR [options]

Options:
-o, --output-dir OUTPUT-DIR
set output directory
-h, --help print this help menu
-v, --verbose be very wordy

変換したいファイルが入っているディレクトリを指定は必ず指定しなければなりません。変換先のディレクトリは、オプション -o で指定できますが、指定しなければ入力ディレクトリと同じになります。

上記リンクのレポジトリの中にテスト用の test/inputtest/output というディレクトリがあるので、こう使えます。

cargo run -- test/input -o test/output

cargo run に与えるオプションは、-- 以前のものが cargo run のオプションとしてみなされ、-- 以後のものは実行したバイナリのオプションとしてみなされます。


コード


1. getopts でオプション解析

Rust でオプション解析を行うためには色々な crate があるみたいですが、中でもシンプルなのが公式によって開発されている getopts です。

今回書いた SSG のオプションはこういった構成です。


  • 必要な引数:入力ディレクトリ

  • 任意の引数:出力ディレクトリ (-o,--output-dir)

  • 任意のフラグ:ヘルプ (-h, --help)、verbose (-v, --verbose)

各オプションが引き起こす挙動もまとめます。


  • 入力ディレクトリ:入力ディレクトリを設定する。

  • 出力ディレクトリ:指定されれば、出力ディレクトリを設定する。指定されなければ、出力ディレクトリを入力ディレクトリと同じに設定する。

  • ヘルプ:指定されれば、ヘルプを表示する。

  • verbose:指定されれば、何かする度に色々うるさく表示する。

ではコードを書きます。

まず、渡されたオプションを解析した後、その情報をまとめて格納する struct を作ります。

use std::path::PathBuf;

struct ProgramInfo {
input_dir: PathBuf, // 入力ディレクトリ
output_dir: PathBuf, // 出力ディレクトリ
verbose: bool, // verbose
}

std::path::PathBuf はパスを表す型です。PathBufPath の関係は、String&str の関係に近いのかな?と自分は思っています。パスを接合する時に使える join とか、色々便利な機能があるので、PathBuf を使います。

ヘルプ (-h) は、渡されたらヘルプメッセージを表示すればいいだけなので、特にこの struct にフィールドを設ける必要はありません。

次に、parse_options というオプション解析を行う関数を書きます。成功すれば先程定義した ProgramInfoOk() に入れて返し、失敗すれば Err(()) を返してくれればいいですね。

extern crate getopts;

use getopts::Options;
use std::env;

fn parse_options() -> Result<ProgramInfo, ()> {
...
}

fn main() {
let info = parse_options()
.expect("bad program options");
}

では、parse_options を実装します。

std::env::args はプログラムに渡された引数のイテレータを返します。引数の型は String で、Unicode じゃないと panic されます。これを使って、引数を Vec にまとめて、後で getopts の関数に渡します。

fn parse_options() -> Result<ProgramInfo, ()> {

let args: Vec<String> = env::args().collect();
let program = &args[0]; // 最初の引数はプログラムの名前
...

program は、後でヘルプメッセージを表示する時に使います。

getopts::Options を作って、それに解析したいオプションを登録していきます。optopt とか optflag とかの関数については、docs に詳しく書いてあります。

optopt に渡される引数は、1 個目がハイフン 1 文字で指定される名前 (short name)、2 個目がハイフン 2 文字で指定される名前 (long name)、3 個目がヘルプで表示されるメッセージ、4 個目がヘルプメッセージで引数の値の例として表示される文字列です。このコードでは 4 個目の引数に "OUTPUT-DIR" を指定しているので、ヘルプメッセージはこうなります。

...

Options:
-o, --output-dir OUTPUT-DIR
set output directory
...

Options::parse で先程の args を解析させます。args の最初の項目は、プログラム名であってオプションではないので、parse に渡すのは args[..] ではなく args[1..] となっています。成功すれば、getopts::Matches という struct を返してくれます。

    ...

let mut opts = Options::new();
opts.optopt("o", "output-dir", "set output directory", "OUTPUT-DIR");
opts.optflag("h", "help", "print this help menu");
opts.optflag("v", "verbose", "be very wordy");

let matches = opts.parse(&args[1..]).unwrap_or_else(|f| panic!(f.to_string()));
...

次に、各オプションが引き起こすべき挙動を実装していきます。

まずはヘルプ (-h)。指定されていれば、ヘルプメッセージを表示します。ここでは、与えられたオプションが指定されているかどうかを教えてくれる関数 Matches::opt_present を使っています。

    ...

if matches.opt_present("h") {
print_usage(program, &opts);
}
...

print_usage は、こんな感じです。先程定義した program を使っています。

fn print_usage(program: &str, opts: &Options) {

let brief = format!("usage: {} INPUT-DIR [options]", program);
print!("{}", opts.usage(&brief));
}

Options::usage は、うまいことヘルプメッセージをフォーマットしてくれます。

ではオプションに戻ります。次のオプションは、入力ディレクトリです。これは必要な引数なので、指定されていなければヘルプメッセージを表示し、Err(()) を返します。指定されていれば、PathBuf::from を使って PathBuf である input_dir に格納します。

    ...

let mut input_dir = match matches.free.is_empty() {
true => {
print_usage(program, &opts);
return Err(());
},

false => PathBuf::from(matches.free[0].clone()),
};
...

出力ディレクトリは、指定されていればそれを使い、そうでなければ入力ディレクトリを clone() します。

    ...

let mut output_dir = match matches.opt_str("o") {
Some(s) => PathBuf::from(s),
None => input_dir.clone(),
};
...

入・出力ディレクトリが相対パスだったら、絶対パスに変換します。このコードは、後にエラーメッセージの表示とかに役立つかと思い、書いたのですが、今の所は必要ありません。

    ...

let pwd = env::current_dir().unwrap();
if input_dir.is_relative() {
input_dir = pwd.join(input_dir);
}
if output_dir.is_relative() {
output_dir = pwd.join(output_dir);
}
...

ここまで来れば、解析成功です。Ok() に結果をまとめて返しましょう。

    Ok(ProgramInfo {

input_dir: input_dir,
output_dir: output_dir,
verbose: matches.opt_present("v"),
})
}


2. グローバルアセットのコピー

CSS や、ブログのヘッダなどに使われる画像ファイルなどのアセットは、出力ディレクトリにコピーして、変換した HTML から引用することにします。

CSS のように、全てのページで使われるアセットもあれば、特定の投稿に含まれる画像のように、各ページで異なるアセットもあります。この二種類のアセットをグローバルアセットローカルアセットとでも呼びましょう。今回は、まずグローバルアセットをどう保管するかを考えます。

writer2 では、Skeleton というフレームワークをフォントを変えて使っています。CSS ファイル二つで超軽量です。この 2 つのアセットが、レポジトリの assets というディレクトリ下に入っています。

writer2

├─assets
│ normalize.css
│ skeleton.css

変換された HTML ファイルは、全てこの 2 つのアセットを使います。例として、レポジトリにある test/input ディレクトリを見てみます。出力ディレクトリである test/output の中身も見てみます。

└─test

├─input
│ │ notpost.txt
│ │ post.md
│ │
│ └─inner
│ inner-notpost.txt
│ inner-post.md

└─output
│ notpost.txt
│ post.html

├─assets
│ normalize.css
│ skeleton.css

└─inner
inner-notpost.txt
inner-post.html

test/output の中でも、post.htmlinner/inner-post.html はディレクトリの深度が違うので、HTML 内での CSS の引用パスも違います。


output/post.html

<link rel="stylesheet" href="assets/normalize.css" type="text/css">

<link rel="stylesheet" href="assets/skeleton.css" type="text/css">


output/inner/inner-post.html

<link rel="stylesheet" href="../assets/normalize.css" type="text/css">

<link rel="stylesheet" href="../assets/skeleton.css" type="text/css">

ディレクトリが 1 レベル深くなると、パスの先頭の ../ が増えます。

注意すべきポイントをまとめると、


  1. アセットを一度だけコピーすること

  2. アセットのパスを間違えないこと

ですね。ではコードを書きます。

まずは、アセットを表す struct Asset を作ります。アセットのパスと、種類を指定します。

struct Asset {

path: PathBuf,
asset_type: AssetType,
}

// アセットの種類は、とりあえず CSS だけ
enum AssetType {
Css,
Other,
}

なぜアセットの種類を指定するかというと、それによって対応する HTML タグが違うからです。

Asset を作る時、いちいち Asset { path: ..., asset_type: ... } と書くのは面倒なので、sugar を作ります。

fn make_asset(path: &str, ty: AssetType) -> Asset {

Asset {
path: PathBuf::from(path),
asset_type: ty,
}
}

main で、writer2 が扱うアセットの情報を Vec にまとめましょう。

fn main() {

...
let assets: Vec<Asset> = vec![
make_asset("assets/normalize.css", AssetType::Css),
make_asset("assets/skeleton.css", AssetType::Css),
];

prepare_assets(&info, &assets);
}

この prepare_assets という関数で、アセットのコピーなどの作業を行います。引数として ProgramInfo が必要なのは、アセットのコピー先である出力ディレクトリを知る必要があるからです。

fn prepare_assets(info: &ProgramInfo, assets: &Vec<Asset>) {

let output_dir = &info.output_dir;

for asset in assets {
let path = &asset.path;

let parent_dir = path.parent()
.expect(&format!("asset \"{}\" does not have a parent", path.display()));
let parent_dir = output_dir.join(parent_dir);

fs::create_dir_all(parent_dir.clone())
.expect(&format!("could not create asset directory \"{}\"", parent_dir.display()));

let asset_dir = path.file_name()
.expect(&format!("asset \"{}\" does not have a filename", path.display()));
let asset_dir = parent_dir.join(asset_dir);

fs::copy(path, asset_dir.clone())
.expect(&format!("could not copy asset \"{}\" to \"{}\"", path.display(), asset_dir.display()));
}
}

prepare_assets は、assets の中の各 Asset に対して処理を行っています。asset.path"assets/skeleton.css" で、出力ディレクトリが "test/output" の場合を例にとって説明します。


  1. 出力ディレクトリを output_dir と呼びます。今、output_dir"test/output" です。

  2. path の親ディレクトリを PathBuf::parent で取得し、それを parent_dir と呼びます。今、parent_dir"assets" です。

  3. parent_diroutput_dir の後に接合し、再度 parent_dir と呼びます。今、parent_dir"test/output/assets" です。

  4. fs::create_dir_allparent_dir ディレクトリを作成します。

  5. path のファイル名を PathBuf::file_name() で取得し、それを asset_dir と呼びます。今、asset_dir"skeleton.css" です。これは、本当は asset_path とでも呼ぶべきですね。

  6. asset_diroutput_dir の後に接合し、再度 asset_dir と呼びます。今、asset_dir"test/output/assets/skeleton.css" です。

  7. fs::copypath にあるアセットを asset_dir へコピーします。


fs::create_dirfs::create_dir_all の違いについて

指定したパスに何層もディレクトリがある時に、fs::create_dir_all は再帰的に必要なディレクトリを作成するのに対し、fs::create_dir は最後のディレクトリしか作成してくれないからです。例えば、何もないところに a/b/c というディレクトリを作成したいとします。

//  a を作成して、次に a/b を作成して、最後に a/b/c を作成してくれる

fs::create_dir_all(PathBuf::from("a/b/c"));

// a/b はあらかじめ存在しないので、a/b/c を作成してくれない
fs::create_dir(PathBuf::from("a/b/c"));


PathBuf::display() について

さっきのコードでは、Result::expect のメッセージを format! して作っているのですが、パスを表示させたい時には、PathBuf そのものではなく、PathBuf::display() の戻り値を使う必要があります。これは、PathBuf 自体は std::fmt::Display を実装していないためです。

let path = PathBuf::from("hello/world");

println!("{}", path); // ダメ
println!("{}", path.display()); // OK


終わり

はじめて PathBuf を使ってみたんですが、便利です。次は与えられたディレクトリの中のファイルを再帰的に逐次処理するコードについて書きます。