大好きなRustのみで個人サイトを構築した話

好きな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クレートたちを紹介する。

#主要な依存関係

toml
[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の構造をチェックしてくれることだ。閉じタグの忘れ?属性の書き間違い?全部コンパイラが教えてくれる。

実際のコードを見てほしい。

rust
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: ローカル開発サーバー

記事を書いたらすぐに確認したい。そのために開発サーバーが必要だ。

rust
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を使って記事の処理を並列化している。

rust
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でシェアされたときに表示される画像。これも自前で生成している。

  1. SVGテンプレートに記事タイトルを埋め込む
  2. 日本語フォント(Noto Sans JP)を適用
  3. resvgでPNGに変換

タイトルが長い場合は自動で改行し、フォントサイズも動的に調整する。42〜64pxの範囲で、タイトルの長さに応じて最適なサイズを選ぶ。

#JSON-LD構造化データ

Google検索で記事がリッチに表示されるように、BlogPostingとBreadcrumbListのスキーマを出力している。src/structured_data.rsで管理。

#苦労した点

ここまで書くと「意外と簡単そう」に見えるかもしれない。でも実際は色々と苦労した。

#OGP画像の日本語対応

これが一番大変だった。

SVGで日本語を表示するには、フォントを埋め込む必要がある。でもresvgはシステムフォントを自動で読み込んでくれない。明示的にフォントファイルを読み込ませないといけない。

さらに、タイトルの改行処理。日本語は英語と違って単語の区切りがないから、適切な位置で改行するのが難しい。結局、句読点(「、」「。」「!」「?」など)を改行の目安にするロジックを実装した。

rust
// タイトルを行に分割するロジック(簡略化)
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ブロックの中に直接書いているのだけど、これはこれで管理が大変だ。

rust
let css = r#"
    :root { --header-height: 60px; }
    *, *::before, *::after { box-sizing: border-box; }
    // ...400行以上続く
"#;

html! {
    head {
        style { (PreEscaped(css)) }
    }
}

将来的には外部CSSファイルに分離したいところだ。

#自作SSGのビルドフロー設計

既存のSSGを使っていると意識しないけど、自分で作るとなると「何をどの順番で処理するか」を全部自分で決めないといけない。

現在のビルドフローはこんな感じだ。

  1. dist/ディレクトリを初期化(既存ファイルを削除)
  2. static/から静的アセットをコピー
  3. content/のMarkdownファイルを全部パース
  4. 記事を日付順にソート
  5. 検索インデックス(JSON)を生成
  6. タグページを生成
  7. 各記事ページを生成(OGP画像も同時に)
  8. インデックスページを生成
  9. sitemap.xmlを生成
  10. RSSフィードを生成

最初は「Markdownをパースして出力すればいいでしょ」くらいに思っていた。甘かった。

タグページを生成するには、まず全記事のタグを集計しないといけない。でも記事を生成するときにはタグページへのリンクが必要。つまり、記事パース→タグ集計→タグページ生成→記事ページ生成、という順番になる。

さらにサイドバーには「記事一覧」を表示したいから、全記事の情報が揃ってからでないとページを生成できない。依存関係を整理するだけで結構な時間がかかった。

#Markdownパースの罠

pulldown-cmarkは優秀なMarkdownパーサーだ。でも「パースして終わり」ではなかった。

見出しへのID付与

目次からジャンプできるように、各見出しにIDを付ける必要がある。pulldown-cmarkはデフォルトではIDを付けてくれない。自分でイベントを処理して、見出しテキストからスラッグを生成してIDとして付与する処理を書いた。

rust
// 見出しイベントを処理して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しか書けない身体」になってしまったけど、後悔はない。

好きな言語で、好きなものを作る。このサイトはその証だ。

もしあなたも「既存のフレームワークに満足できない」「自分だけの環境が欲しい」と思っているなら、自作という選択肢もあることを伝えたい。大変だけど、楽しいぞ。