はじめに
最近 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/input
と test/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
はパスを表す型です。PathBuf
と Path
の関係は、String
と &str
の関係に近いのかな?と自分は思っています。パスを接合する時に使える join
とか、色々便利な機能があるので、PathBuf
を使います。
ヘルプ (-h
) は、渡されたらヘルプメッセージを表示すればいいだけなので、特にこの struct
にフィールドを設ける必要はありません。
次に、parse_options
というオプション解析を行う関数を書きます。成功すれば先程定義した ProgramInfo
を Ok()
に入れて返し、失敗すれば 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.html
と inner/inner-post.html
はディレクトリの深度が違うので、HTML 内での CSS の引用パスも違います。
<link rel="stylesheet" href="assets/normalize.css" type="text/css">
<link rel="stylesheet" href="assets/skeleton.css" type="text/css">
<link rel="stylesheet" href="../assets/normalize.css" type="text/css">
<link rel="stylesheet" href="../assets/skeleton.css" type="text/css">
ディレクトリが 1 レベル深くなると、パスの先頭の ../
が増えます。
注意すべきポイントをまとめると、
- アセットを一度だけコピーすること
- アセットのパスを間違えないこと
ですね。ではコードを書きます。
まずは、アセットを表す 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"
の場合を例にとって説明します。
- 出力ディレクトリを
output_dir
と呼びます。今、output_dir
は"test/output"
です。 -
path
の親ディレクトリをPathBuf::parent
で取得し、それをparent_dir
と呼びます。今、parent_dir
は"assets"
です。 -
parent_dir
をoutput_dir
の後に接合し、再度parent_dir
と呼びます。今、parent_dir
は"test/output/assets"
です。 -
fs::create_dir_all
でparent_dir
ディレクトリを作成します。 -
path
のファイル名をPathBuf::file_name()
で取得し、それをasset_dir
と呼びます。今、asset_dir
は"skeleton.css"
です。これは、本当はasset_path
とでも呼ぶべきですね。 -
asset_dir
をoutput_dir
の後に接合し、再度asset_dir
と呼びます。今、asset_dir
は"test/output/assets/skeleton.css"
です。 -
fs::copy
でpath
にあるアセットをasset_dir
へコピーします。
fs::create_dir
と fs::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
を使ってみたんですが、便利です。次は与えられたディレクトリの中のファイルを再帰的に逐次処理するコードについて書きます。