大好きなRustのみで個人サイトを構築した話
- 日本語
- Rust
- Maud
#はじめに
このサイト、見た目は割と普通のブログだと思う。左に記事一覧、真ん中にコンテンツ、右に目次。よくある3カラムレイアウトだ。
でも中身は全部Rustでできている。HTMLテンプレートも、Markdownパーサーも、開発サーバーも、OGP画像生成も、sitemap生成も、RSS配信も。全部。Rust。100%。
「いや、普通にNext.jsとか使えば?」という声が聞こえてくる気がする。ごもっともである。
でも私は5回もフレームワークを引越しした末に、「もう自分で作るしかない」という境地に辿り着いてしまった人間だ。これはその記録である。
#フレームワーク引越し遍歴:あるいは終わらない旅
個人サイトを作ろうと思い立ってから、私は実に5つのアーキテクチャを渡り歩いてきた。振り返ってみると、なかなかに壮絶な歴史である。
#Version 1: SvelteKit時代
最初に選んだのはSvelteKitだった。Svelteの書き心地は良かったし、SvelteKitのファイルベースルーティングも直感的だった。
でも、なんだろう。しっくりこなかった。フレームワークの制約の中でやりくりしている感覚が常にあった。「こうしたいのに、フレームワークがそう設計されていない」というモヤモヤ。
#Version 2: Lume(Deno)への脱走
次に逃げ込んだのはLumeだった。Denoベースの静的サイトジェネレーターで、シンプルさが売りだった。
設定ファイルをTypeScriptで書けるのは良かった。でもDenoエコシステム自体がまだ発展途上で、必要なライブラリがなかったり、あっても動かなかったり。
#Version 3: VitePress - 「ドキュメント向けでは?」
VitePressを選んだのは、Viteの速さに惹かれたからだった。でも使い始めてすぐに気づいた。
これ、ドキュメントサイト向けじゃん。
技術ドキュメントを書くには最高だけど、ブログとして使うにはカスタマイズが必要で、そのカスタマイズがまた大変で…という悪循環。
#Version 4: Zola - Rustへの片思い
ここでようやくRustが登場する。ZolaはRust製の静的サイトジェネレーターで、ビルドが爆速だった。
「おお、Rustだ!」と感動した。でも使っているうちに「Rustで作られた」と「Rustで作る」は全く違うことに気づいた。結局テンプレートはTeraだし、自分でRustを書く機会はほぼない。
私はRustを「使いたかった」のだ。Rust製のツールを「使う」のとは違う。
#Version 5: 自作Rust - 終着駅
そして今、このサイトは完全にRustで自作されている。2025年7月からこの形になった。
フレームワークで簡単に公開できるフェーズはとっくに過ぎていた。せっかくの個人サイトなのだから、自分だけの「オレオレ環境」を構築したい。大好きなRustで、自分が実装できるレベルを世界に公開したい。Webサイト関連の知識も一から勉強したい。
そう思ったら、もう自分で作るしかなかった。
#私とRustの馴れ初め
ここで少し私とRustの関係について話させてほしい。
私がプログラミングを始めたのは2022年のことだ。そして最初に選んだ言語がRustだった。
「初心者がいきなりRust?」と驚かれることがある。確かに無謀だったかもしれない。所有権、ライフタイム、借用チェッカー。初学者を容赦なく殴ってくる概念の数々。
でも振り返ってみると、この選択は正解だった。
Rustのコンパイラは厳しい。本当に厳しい。でもその厳しさは、私を成長させてくれた。コンパイルが通らないたびに「なぜダメなのか」を考え、エラーメッセージを読み、理解する。その繰り返しが、プログラミングの基礎体力を鍛えてくれた。
その後、仕事ではTypeScript、JavaScript、C#も使うようになった。でも不思議なことに、どの言語を書いていても「Rustだったらどう書くか」を考えている自分がいる。
気づけば「Rustしか書けない身体」になってしまった。
今ではフルスタックRustでWebアプリケーションを実装できるようになった。バックエンドはAxum、フロントエンドはLeptos。そしてこの個人サイトもRust100%。
好きな言語で好きなものを作る。エンジニアとしてこれ以上の幸せがあるだろうか。
#技術スタックの紹介
さて、具体的な技術の話をしよう。このサイトを構成するRustクレートたちを紹介する。
#主要な依存関係
[dependencies] maud = "0.27.0" # 型安全HTMLテンプレート axum = "0.8.4" # 開発サーバー pulldown-cmark = "0.13.0" # Markdownパーサー syntect = "5.3.0" # コードシンタックスハイライト rayon = "1.10.0" # 並列処理 resvg = "0.45.1" # SVGレンダリング usvg = "0.45.1" # SVGパース tiny-skia = "0.11.4" # 2Dグラフィックス
#Maud: 型安全なHTMLテンプレート
このサイトの心臓部がMaudだ。RustのマクロでHTMLを書ける。
何が嬉しいかというと、コンパイル時にHTMLの構造をチェックしてくれることだ。閉じタグの忘れ?属性の書き間違い?全部コンパイラが教えてくれる。
実際のコードを見てほしい。
use maud::{DOCTYPE, Markup, PreEscaped, html}; pub fn layout( config: PageConfig, sidebar_left_markup: Markup, main_content_markup: Markup, sidebar_right_markup: Markup, ) -> Markup { html! { (DOCTYPE) html lang="ja" { head { meta charset="utf-8"; meta name="viewport" content="width=device-width, initial-scale=1"; title { (config.page_title) } link rel="canonical" href=(config.canonical_url); } body { header { div class="header-content" { h1 { a href="/" { "dnfolio" } } } } div class="container" { aside class="sidebar-left" { (sidebar_left_markup) } main class="main-content" { (main_content_markup) } aside class="sidebar-right" { (sidebar_right_markup) } } footer { p { "© 2025 Daiki Nakashima" } } } } } }
これがRustのコードだ。HTMLっぽいけど、完全にRustの世界。変数の埋め込みは(変数名)で、ループは@forで、条件分岐は@ifで書ける。
JSXに似ているようで違う。JSXはJavaScriptの中にHTMLを書くけど、MaudはRustのマクロだ。つまり、本当にコンパイル時にチェックされる。ランタイムエラーでHTMLが壊れる心配がない。
#axum: ローカル開発サーバー
記事を書いたらすぐに確認したい。そのために開発サーバーが必要だ。
pub async fn run() -> anyhow::Result<()> { let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); let serve_dir = ServeDir::new("dist"); let app = Router::new().fallback(get_service(serve_dir)); println!("Listening on http://{addr}"); axum::serve(tokio_listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; Ok(()) }
cargo run -- serveで起動すると、dist/ディレクトリを配信してくれる。シンプルだけど、これで十分だ。
#pulldown-cmark + syntect: Markdown処理
ブログ記事はMarkdownで書いている。pulldown-cmarkでHTMLに変換し、syntectでコードブロックにシンタックスハイライトを適用している。
見出しにはIDを自動付与して、右サイドバーの目次からジャンプできるようにしている。地味だけど、実装してみると意外と面倒な処理だ。
#rayon: 並列処理で爆速ビルド
記事が増えてくるとビルド時間が気になる。rayonを使って記事の処理を並列化している。
articles.par_iter().for_each(|article| { // 各記事の処理が並列で走る });
par_iter()に変えるだけで並列化完了。Rustのエコシステム、最高。
#自前で実装したSEO機能
個人サイトとはいえ、せっかく書いた記事は読んでもらいたい。SEO対策も自前で実装している。
#sitemap.xml
src/sitemap.rsで全ページのURLを出力している。
- ホームページ: priority 1.0
- 記事ページ: priority 0.8
- タグページ: priority 0.7
更新日時も各記事のメタデータから取得して設定している。
#RSSフィード
src/rss.rsで最新20件の記事をRSS 2.0形式で配信している。RSSリーダーを使っている人はぜひ購読してほしい(いるのか?)。
この実装で LAPRASのクローラー が記事を取得出来るようになった。転職予定は全く無いが、「個人サイトのRSS機能が正常に動作しているか」を確認させてもらえた。素晴しいポートフォリオサイトだ。
#OGP画像の自動生成
SNSでシェアされたときに表示される画像。これも自前で生成している。
- SVGテンプレートに記事タイトルを埋め込む
- 日本語フォント(Noto Sans JP)を適用
- resvgでPNGに変換
タイトルが長い場合は自動で改行し、フォントサイズも動的に調整する。42〜64pxの範囲で、タイトルの長さに応じて最適なサイズを選ぶ。
#JSON-LD構造化データ
Google検索で記事がリッチに表示されるように、BlogPostingとBreadcrumbListのスキーマを出力している。src/structured_data.rsで管理。
#苦労した点
ここまで書くと「意外と簡単そう」に見えるかもしれない。でも実際は色々と苦労した。
#OGP画像の日本語対応
これが一番大変だった。
SVGで日本語を表示するには、フォントを埋め込む必要がある。でもresvgはシステムフォントを自動で読み込んでくれない。明示的にフォントファイルを読み込ませないといけない。
さらに、タイトルの改行処理。日本語は英語と違って単語の区切りがないから、適切な位置で改行するのが難しい。結局、句読点(「、」「。」「!」「?」など)を改行の目安にするロジックを実装した。
// タイトルを行に分割するロジック(簡略化) fn split_title(title: &str, max_chars: usize) -> Vec<String> { // 句読点で分割を試みつつ、文字数制限を考慮 // ... }
#Maudの学習曲線
Maudの記法は独特だ。最初は戸惑った。
- 変数の埋め込み:
(変数) - 生HTMLの埋め込み:
(PreEscaped(html_string)) - 条件分岐:
@if condition { ... } @else { ... } - ループ:
@for item in items { ... }
特にPreEscapedの使い所が最初わからなかった。Markdownから変換したHTMLをそのまま埋め込むときに必要なのだけど、普段は使わない方がいい(XSS対策のため)。
また、このサイトではCSSをインラインで書いている。400行以上ある。Maudのstyleブロックの中に直接書いているのだけど、これはこれで管理が大変だ。
let css = r#" :root { --header-height: 60px; } *, *::before, *::after { box-sizing: border-box; } // ...400行以上続く "#; html! { head { style { (PreEscaped(css)) } } }
将来的には外部CSSファイルに分離したいところだ。
#自作SSGのビルドフロー設計
既存のSSGを使っていると意識しないけど、自分で作るとなると「何をどの順番で処理するか」を全部自分で決めないといけない。
現在のビルドフローはこんな感じだ。
dist/ディレクトリを初期化(既存ファイルを削除)static/から静的アセットをコピーcontent/のMarkdownファイルを全部パース- 記事を日付順にソート
- 検索インデックス(JSON)を生成
- タグページを生成
- 各記事ページを生成(OGP画像も同時に)
- インデックスページを生成
- sitemap.xmlを生成
- RSSフィードを生成
最初は「Markdownをパースして出力すればいいでしょ」くらいに思っていた。甘かった。
タグページを生成するには、まず全記事のタグを集計しないといけない。でも記事を生成するときにはタグページへのリンクが必要。つまり、記事パース→タグ集計→タグページ生成→記事ページ生成、という順番になる。
さらにサイドバーには「記事一覧」を表示したいから、全記事の情報が揃ってからでないとページを生成できない。依存関係を整理するだけで結構な時間がかかった。
#Markdownパースの罠
pulldown-cmarkは優秀なMarkdownパーサーだ。でも「パースして終わり」ではなかった。
見出しへのID付与
目次からジャンプできるように、各見出しにIDを付ける必要がある。pulldown-cmarkはデフォルトではIDを付けてくれない。自分でイベントを処理して、見出しテキストからスラッグを生成してIDとして付与する処理を書いた。
// 見出しイベントを処理してIDを付与 Event::Start(Tag::Heading { level, id, .. }) => { // 見出しテキストを収集開始 } Event::End(TagEnd::Heading(level)) => { // テキストからスラッグを生成してIDに設定 let id = slug::slugify(&heading_text); }
さらに、同じ見出しが複数あると IDが重複する。「まとめ」という見出しが2つあったら両方#matomeになってしまう。重複チェックしてmatome-2のようにサフィックスを付ける処理も必要だった。
コードブロックのシンタックスハイライト
コードブロックに色を付けたい。syntectを使っているが、これもpulldown-cmarkのイベントストリームに割り込んで処理する必要がある。
言語指定がある場合は該当のシンタックス定義を探し、なければプレーンテキストとして処理。シンタックスハイライトされたHTMLを生成して、元のイベントストリームに差し替える。
地味に面倒だった。
#ファイル構成の設計
「記事のURLをどうするか」も悩みポイントだった。
/posts/記事タイトル/にするか/posts/記事スラッグ/にするか/2025/01/記事スラッグ/のように日付を入れるか
結局、/posts/{slug}/index.htmlという構成にした。スラッグはフロントマターで指定できるし、指定がなければファイル名から自動生成する。
出力先のディレクトリ構成も何度か変えた。最初はフラットに全部dist/直下に置いていたけど、OGP画像が増えてきてカオスになったのでdist/ogp/に分離した。
正解がないから、使いながら調整していくしかない。これも自作の醍醐味といえば醍醐味だ。
#今後の展望
#検索機能の自作
現在も簡易的な検索機能はある。ビルド時にsearch-index.jsonを生成して、クライアント側のJavaScriptで検索している。
でもこれ、記事が増えてくるとJSONが巨大になる。将来的にはもっとスマートな方法を考えたい。SQLiteで全文検索?WebAssemblyで検索ロジックを実装?夢は広がる。
#UIの継続的な改善
正直、デザインは得意ではない。今のUIは「とりあえず読めればいい」レベルだ。
少しずつ改善していきたい。ダークモード対応、レスポンシブデザインの改善、アニメーションの追加…やりたいことはたくさんある。
#Rust100%の維持
このサイトの売りは「Rust100%」だ。この純度は維持したい。
JavaScriptは最小限に抑えている。今あるのは検索機能とメニュートグル程度。将来的にはこれもWebAssemblyに置き換えられたら面白いかもしれない。
#まとめ
5回のフレームワーク引越しを経て、私はようやく落ち着ける場所を見つけた。自分で作ったRust100%の個人サイトだ。
「フレームワーク疲れ」の解決策は、自分で作ることだった。逆説的だけど、一番自由なのは一番面倒な方法だったりする。
2022年にプログラミングを始めて、最初の言語としてRustを選んだ。その選択は間違っていなかった。厳格なコンパイラに鍛えられ、今では「Rustしか書けない身体」になってしまったけど、後悔はない。
好きな言語で、好きなものを作る。このサイトはその証だ。
もしあなたも「既存のフレームワークに満足できない」「自分だけの環境が欲しい」と思っているなら、自作という選択肢もあることを伝えたい。大変だけど、楽しいぞ。