Flisan's Personal Blog

← 記事一覧へ


活動記録

Rust でブログサイトのジェネレータを作ってみました


フライさんです。新しくブログサイトを作ってみました!

普段 Web サイトを作るときは、もちろん JavaScript / TypeScript にすごいお世話になるのですが、今回は JavaScript を使わず、Rust で HTML/CSS を生成する形で Web サイトを作っています

今回は、このブログの技術的側面についてご紹介したいと思います。

今回のブログサイトの概要

なんで新しく生やしたかとか、そういう話の前に、どういう構成でこのブログサイトができているのかをご紹介します。

今回作成したブログサイトジェネレータ (Platemaker) は、記事ページ用・記事一覧ページの HTML テンプレートMarkdown 記事 を取り、Markdown 記事の内容を HTML テンプレートに当てはめることでサイトを生成します#1。図みたいな感じです ↓

ちなみに本題には全く関係ないですが、生成時はこういう最高のログが表示されます。

前のブログサイトで感じていたつらみ

もし覚えている方がいたら「なんで!?!」という感じなのですが、私はすでにブログサイトを持っています。ただ、このブログサイトにはいくつかつらみがありました。

結構ボロクソに書いてしまいましたが、もちろん良かった点もあります!MDX 機能はあらかたちゃんと動いていましたし、デザインも作りたいものを実装できていました。

ただ、記事を書くときのつらみがどうしても大きく、それがモチベを引き下げてしまって、せっかくブログサイトを作ったのに記事を書かない…… ということになってしまいました。

技術選定について

先にも書きましたが、今回は JavaScript はなし、Rust に HTML/CSS を全部生成させる という方法でやりました。

JavaScript いらなくね?

ブログサイトというのは、事前定義されたコンテンツを HTML/CSS に起こして閲覧できればそれで耐える ので、クライアント側で必要な処理というのは極めて少ないです。Clickable なものといえばハイパーリンクくらいです。

これであれば、クライアント側で JavaScript を使う必要は実はなさそうです。MDX に関しても、前のブログサイトでそれなりに痛い目を見たので対応しない方針で行こうと決めていたので、なお JavaScript いらないのでは?と感じました。

また、「クライアント側で JavaScript がいらないなら、サーバ側を JavaScript で書く意味もなさそうです。バックエンド側はかなり選択肢が広いですし、わざわざ JS を選ばなくても、別の言語を選んで良いように感じます。

これらの理由から、JavaScript は今回は使わないという決定をしました。このサイトは JavaScript が無効になっていても完璧に動作します! 🏃

Rust で一つでかいのを書きたかった

私は Rust が好きです! 好きな理由をオタク語りできる、数少ないものの一つです。

Rust を選んだ理由はほぼもうこれだけです。 あとは、Rust でなにか完成させたことが何かとない、というのもひとつあります。

生の HTML/CSS でどこまで行けるか知りたかった

普段は JavaScript にお世話になり、動的に DOM をいじることで Web サイトを動かしていますが、そういうのは全部抜きにして、ただの静的な HTML でどこまで作れるんだろうというのが気になっていた、というのもありました。

今の CSS は、Variables ですとか、ネストですとか、強力な機能がたくさんあります。ここまで環境が整っていれば、実は CSS-in-JS とか CSS Preprocessor に頼らなくてもかなり書けるのでは? という気持ちになりました。

Platemaker の詳細

概要

大まかにはこういうフローで動いています。

  1. 記事となる Markdown ファイルを列挙する。
  2. テンプレートファイルを読み出す。
  3. 各 Markdown から、メタデータと記事本文の HTML を生成する。
  4. HTML テンプレートに、生成したメタデータと HTML を当てはめる。
  5. 生成先ディレクトリに HTML を書き込み、CSS や画像などをコピーする。

クレートの設計

処理のステップごとにコードベースをわかりやすく分けられそうだったので、クレートも区切って整理しています。こんな感じです:

bin  ............  CLI 周りのハンドリングと Watch 処理
crates/
    construct ...  ファイルを指定したディレクトリ構造で書き込み
    core  .......  全クレートで共通の構造体やログ処理等
    markdown  ...  Markdown のパース処理。ここが一番でかい
    template  ...  テンプレートへの値埋め込み処理の実装
    structure ...  パスを解析して、記事ファイル・月ごとのディレクトリ等の判別をする処理
    website  ....  Web サイトのディレクトリ構造を生成
    widgets  ....  Web サイトのメタデータに応じて動的に変えたい部分の実装

以降は、Platemaker でどんな処理をしているか、大変だった部分について概要を紹介しようと思います。 それぞれ、気が向いたら詳細を記事に残すかもしれません。

Markdown から HTML に起こす処理

Markdown から HTML に起こす処理については、pulldown-cmark クレートを使ってカスタマイズ・生成しています。

普通に Markdown から HTML に起こすだけであれば、markdown で足ります#2。ただ、このクレートの生成結果にはいくつか弱点が存在していました:

これらを自分で対応しようとすると、以下のようなアプローチになります:

ただ、このクレートでは Markdown の AST を得ること自体はできる一方で、その AST を生成処理に使う…… というところまではできず、Markdown/HTML の AST に手を加えて HTML を生成するということができません。

一方、pulldown-cmark#3 では、Markdown の AST を HTML 出力用に単純化・Flatten した enum “Event” の Iterator を得ることができます。HTML を生成するための関数に関しても、impl Iterator<Item = Event> を渡せばそれで良いので、Iterator を加工するだけで好きに生成結果をいじれるようになっています!

ただの Event の Iterator を受け取って加工して渡すだけでいいので、かなりシンプルなアーキテクチャで生成結果に手を加えることができます。

この手法が良さそうだったので、今回はその手法でやることにしました。

カスタマイズ用に自前で準備する Iterator は、ステートマシンのような挙動をするようにしています。もともとある Iterator から、「フロントマターはじめ」「フロントマター終わり」「コードブロックはじめ」「コードブロックおわり」みたいな、タグの開始/終了を示す Event が流れてくるので、それを見て自身のステートを変更して、生成結果をステートに応じて調整する…… ということをしています。

この加工処理によって、どのような機能を実装しているかを紹介します!

シンタックスハイライトの実装

上の図でも述べましたが、``` で囲まれているタイプのコードブロックが現れたら、シンタックスハイライト済み HTML を生成して、Event を差し替えるようにしています。シンタックスハイライト済み HTML の生成には syntect を使用しています。

絵文字 ✨ の適切なパース

:sparkles: ✨ みたいなやつのパースです。先にも書きましたが、単純な文字列置換ではなく、コードブロック内はパースしちゃいけない、、みたいな、コンテキストによる条件分けが必要なので、この Iterator の中でパース/置換処理を行っています。

:emoji: から Unicode を得る処理には emojis クレートを使っています。その後、emojis クレートから得た Unicode を元に、Twemoji の SVG への参照に変換しています。

途中で Markdown を改行した場合の処理

既定だと、文の途中で改行を入れると半角スペースが入ります。こういう感じで ↓

例えば、
こういう
テキストは
こうなる
"例えば、 こういう テキストは こうなる"

これは、英語とか、スペースで単語を区切る言語であれば適切な挙動なのですが、日本語の場合はスペースで単語を区切らないので少し違和感があります。

このスペースは、Event::Text(_) の直後に来る Event::SoftBreak に対応して生成されるので、Event::Text(_) の直後に Event::SoftBreak が来た場合、それは無視するようにしています。

この処理によって、Markdown 上では改行して行の文字数を抑えつつ、ブログサイト上ではつながって表示されるので、日本語の文章いい感じに表示されるようになりました!#4

脚注処理

markdown クレートは脚注をめっっちゃいい感じにハンドリングしてくれます。文章のどこに脚注の定義があっても、ちゃんとそれを最後の方に集約してくれます。一方、pulldown-cmark は、パースはちゃんとしてくれても、HTML 生成にあたってはそこまでしっかり面倒は見てくれません。なので、脚注生成処理に関しては自分で実装を行い、ちゃんと集約するようにしました。

本文と脚注間のジャンプも含め、すべてを自分で実装することになったので、せっかくなのでいろいろやってみました。

以上が、行っている加工処理の内容です! 各処理ごとに関数をいい感じに分けれるようにしているので、拡張性も変更性も高いです。今後もいろいろな機能を追加していきます!

Watch 処理

Markdown 記事が更新された際に、対応する HTML を更新するための処理をしています。すべての HTML を毎回再生成しているのではなく、特定の HTML だけを生成するようにしています!ここは、前ブログサイトでモチベが維持できない大きな原因だったのでこだわりました。

ファイルシステムの watch には、notify クレートを使っています。ただ、1 つの操作に見えても、ファイルシステム上では複数の操作が行われている……みたいなことがあるようで、そのままだと大量のイベントが飛んできて処理が重複します。これを防ぐために、notify_debouncer_full クレートでイベントの debouncing をしています。

また、生成処理を終了できるように、Ctrl+C の受信が必要です。Ctrl+C の受け付けには、ctrlc を使っています。

これらのクレートからのイベントはチャンネルで降ってきます。各クレートからの Channel を select できるように、crossbeam-channel を使っています。

テンプレート補完処理

先述の通り、Markdown から HTML に起こす処理にあたっては、事前に用意したテンプレートにメタデータ等のデータを流し込んでいます。画像でちらっと出しましたが、テンプレートには ${title} という、JS/TS の String Interpolation と同じ形のプレースホルダーがあり、そこに文字列を流し込むようにしています。

このプレースホルダーを正規表現を使って実際のデータに置き換えることで、Web ページとして完成させています。

要所要所で String::replace(${title}, article.title) みたいなことをしているわけではなく、テンプレートエンジンは別で実装しています。パラメータを変えれば ${x} 形式以外にも対応できるようにしています!

感想

うれしみ

先にも少し書きましたが、いろいろな嬉しみがありました。

課題があるところ

主にコード品質ですが、こういう問題を感じました。

でも、どれも個人開発であればそんなに気にしなくていいかなと思って、あまり気にしていません。

今回は、ある程度ちゃんとコードがメンテできて、使えればよかったというのと、個人開発が相手なので、特に気にせずに書いています。もちろん、業務ならもうちょっとちゃんと気にしたいですが……

今後について

あまり深くは考えていませんが、高い拡張性を持ったコードベースとすることができたので、今後も機能を拡張して、楽しく記事を書けるブログサイトにしたいと考えています!#7