
2026/03/27 2:16
エリクサーとフェニックスでブログを構築する
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
このブログでは、Elixir の Phoenix フレームワークと NimblePublisher を使って完全に機能する個人ウェブサイトを構築する方法を紹介しています。
• アーキテクチャ:Phoenix はサーバー側でレンダリングを行い、内の Markdown 投稿はモジュール属性へ事前コンパイルされるため、実行時の処理が不要です。priv/posts/**/*.md
• スタイリングとハイライト:Makeup が構文ハイライトを担当し、Tailwind CSS のクラスは Earmark タグプロセッサーを通じて注入されます。
• デプロイメントパイプライン:マルチステージ Dockerfile(Debian trixie 上の Elixir 1.18.4 / Erlang 28.0.2)を GitHub Actions でビルドし、Hetzner の Dokploy を介して自動構築とローリングアップデートで展開します。Dependabot が依存関係を最新に保ちます。
• CI/CD:ワークフローは、mix compile、mix format、およびmix credoを実行します。mix test
• SEO とフィード:Phoenix コントローラが RSS フィード()を提供し、サイトマップジェネレーターはすべてのページと投稿を一覧化し、動的に Open Graph/Twitter メタデータを各投稿に挿入します。JolaDevWeb.RssXML
• レガシー URL サポート:プラグインが古いBlogRedirectリンクを新しいblog.jola.dev/*ルートへ書き換えます。/posts/:id
• AI‑フレンドリーエンドポイント:追加のプレーンテキストリスト(、llms.txt)が AI がコンテンツを取得できるように投稿を公開します。llms-full.txt
• 呼びかけ:著者は他の Elixir 開発者にこのスタックを採用することを奨励し、Hetzner(紹介リンク付き)を推奨し、貢献を歓迎しています。完全なソースコードはで入手できます。https://github.com/joladev/jola.dev
本文
TL;DR
Elixirで構築したPhoenixアプリケーションは、サーバー側でページをレンダリングします。ブログ投稿はMarkdownファイルとして保存され、NimblePublisherによってモジュール属性にコンパイルされます。このサイトは自己ホスト型のDokploy(Hetzner)上で稼働し、bunny.netをフロントエンドに利用しています。
Phoenix を選んだ理由
以前は Hakyll などの静的サイトジェネレーターを使っていましたが、純粋な静的サイトでは実現できないインタラクティブな実験を追加したいと考えました。Phoenix は完全にコントロールできますので、サードパーティツールに依存せず、Ectoやデータベースなしで高速なサーバーサイドレンダリングが可能です。
結果: PageSpeed Insights で確認できるほどの高速ページ(Blazingly fast pages)。
NimblePublisher
ブログの核は NimblePublisher です。設定は
JolaDev.Blog にあります:
defmodule JolaDev.Blog do use NimblePublisher, build: JolaDev.Blog.Post, from: Application.app_dir(:jola_dev, "priv/posts/**/*.md"), as: :posts, html_converter: JolaDev.Blog.MarkdownConverter, highlighters: [:makeup_elixir] end
配下の Markdown ファイルをすべて読み込みます。priv/posts- フロントマターを解析します。
- Markdown を HTML に変換(カスタムコンバータ経由)。
- 結果はモジュール属性に保存され、実行時には何も起きません。
投稿ヘルパー
@posts Enum.sort_by(@posts, & &1.date, {:desc, Date}) @tags @posts |> Enum.flat_map(& &1.tags) |> Enum.uniq() |> Enum.sort() def all_posts, do: @posts def all_tags, do: @tags def posts_by_tag(tag) do Enum.filter(all_posts(), fn post -> tag in post.tags end) end def find_by_id(id) do Enum.find(all_posts(), &(&1.id == id)) end
Markdown 出力に Tailwind クラスを注入
Earmark を使って Tailwind のクラスを挿入します:
Earmark.Options.make_options!( registered_processors: [ Earmark.TagSpecificProcessors.new([ {"a", &Earmark.AstTools.merge_atts_in_node(&1, class: "underline")}, {"h1", &Earmark.AstTools.merge_atts_in_node(&1, class: "text-3xl py-4")}, {"h2", &Earmark.AstTools.merge_atts_in_node(&1, class: "text-2xl py-4")}, {"h3", &Earmark.AstTools.merge_atts_in_node(&1, class: "text-xl py-4")}, {"p", &Earmark.AstTools.merge_atts_in_node(&1, class: "text-md pb-4")}, {"code", &Earmark.AstTools.merge_atts_in_node(&1, class: "")}, {"pre", &Earmark.AstTools.merge_atts_in_node( &1, class: "mb-4 p-1 py-4 overflow-x-scroll border-y" )}, {"ol", &Earmark.AstTools.merge_atts_in_node(&1, class: "list-decimal")}, {"ul", &Earmark.AstTools.merge_atts_in_node(&1, class: "list-disc pb-4")}, {"blockquote", &Earmark.AstTools.merge_atts_in_node( &1, class: "pl-4 border-l-2 mb-4 border-purple-700" )} ]) ] )
フロントエンド
- サーバーサイドの Phoenix テンプレート
- Tailwind CSS(DaisyUI は不要)
- 最小限の JavaScript:モバイルメニュー切替と Phoenix topbar
- ダークモード対応
CI
GitHub Actions が push/PR 毎に実行されます:
mix compile --warnings-as-errors mix format --check-formatted mix credo --strict mix test
Dependabot で依存関係を自動更新。Elixir の小さな依存グラフは npm よりも驚きが少ないです。
デプロイ
-
Dockerfile – 複数ステージ、Phoenix の例に基づく。
- ビルド時にのみほとんどの依存関係を取得。
、Elixir 1.18.4
、Erlang 28.0.2
を使用。Debian trixie-slim
- ビルド時にのみほとんどの依存関係を取得。
-
Dokploy – 自己ホスト型 PaaS(Heroku ライク)。
ビルド・デプロイ・ネットワーキング・ローリングアップデート・ロールバック・プレビュー構築などを自動化。
公開フロー
- PR を作成 → CI が走る。
- マージ → Dokploy がリポジトリを取得し、Docker イメージをビルド、レプリカを更新。
- デプロイ完了は約30秒で、キャッシュ済みレイヤーが活用されます。
Hetzner 上にホストしています。最近の価格上昇後もコストパフォーマンスは圧倒的です。必要なら他サービスへ移行も可能です。
小さなユーティリティ
RSS フィード
Controller (
)JolaDevWeb.RssXML
defmodule JolaDevWeb.RssXML do use JolaDevWeb, :html embed_templates "rss_xml/*" def format_rfc822(%Date{} = date) do DateTime.new!(date, ~T[00:00:00], "Etc/UTC") |> format_rfc822() end def format_rfc822(%DateTime{} = datetime) do Calendar.strftime(datetime, "%a, %d %b %Y %H:%M:%S +0000") end end
XML テンプレート (
)rss_xml/rss.xml.eex
<?xml version="1.0" encoding="UTF-8"?> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"> <channel> <title>jola.dev</title> <link><%= url(~p"/") %></link> <description>Blog posts from jola.dev</description> <language>en-us</language> <lastBuildDate><%= JolaDevWeb.RssXML.format_rfc822(DateTime.utc_now()) %></lastBuildDate> <atom:link href="<%= url(~p"/rss.xml") %>" rel="self" type="application/rss+xml"/> <%= for post <- @posts do %> <item> <title><%= post.title %></title> <link><%= url(~p"/posts/#{post.id}") %></link> <description><![CDATA[<%= post.description %>]]></description> <content:encoded><![CDATA[<%= post.body %>]]></content:encoded> <pubDate><%= JolaDevWeb.RssXML.format_rfc822(post.date) %></pubDate> <guid isPermaLink="true"><%= url(~p"/posts/#{post.id}") %></guid> <author><%= post.author %></author> </item> <% end %> </channel> </rss>
サイトマップ
Controller (
)JolaDevWeb.SitemapController
defmodule JolaDevWeb.SitemapController do use JolaDevWeb, :controller def index(conn, _params) do sitemap = JolaDev.Sitemap.generate() conn |> put_resp_content_type("text/xml") |> send_resp(200, sitemap) end end
ジェネレーター (
)JolaDev.Sitemap
defmodule JolaDev.Sitemap do alias JolaDev.Blog @host "https://jola.dev" def generate do """ <?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> #{generate_static_pages()}#{generate_tag_pages()}#{generate_blog_posts()} </urlset> """ end defp generate_static_pages do pages = [ %{loc: @host, changefreq: "monthly", priority: "1.0"}, %{loc: "#{@host}/about", changefreq: "monthly", priority: "0.8"}, %{loc: "#{@host}/projects", changefreq: "weekly", priority: "0.9"}, %{loc: "#{@host}/talks", changefreq: "monthly", priority: "0.7"}, %{loc: "#{@host}/posts", changefreq: "weekly", priority: "0.9"} ] Enum.map_join(pages, "\n", &url_entry/1) end defp generate_tag_pages do Blog.all_tags() |> Enum.map(fn tag -> %{loc: "#{@host}/posts/tag/#{tag}", changefreq: "weekly", priority: "0.6"} end) |> Enum.map_join("\n", &url_entry/1) end defp generate_blog_posts do Blog.all_posts() |> Enum.map(fn post -> %{ loc: "#{@host}/posts/#{post.id}", lastmod: Date.to_iso8601(post.date), changefreq: "monthly", priority: "0.8" } end) |> Enum.map_join("\n", &url_entry/1) end defp url_entry(params) do """ <url> <loc>#{params.loc}</loc> #{if params[:lastmod], do: "<lastmod>#{params.lastmod}</lastmod>", else: ""} <changefreq>#{params.changefreq}</changefreq> <priority>#{params.priority}</priority> </url> """ end end
ブログリダイレクトプラグ
blog.jola.dev の旧 URL をマイグレーション後も機能させます。
defmodule JolaDevWeb.Plugs.BlogRedirect do import Plug.Conn def init(_), do: [] def call(conn, _opts) do if conn.host == "blog.jola.dev" do ids = JolaDev.Blog.ids() path = strip_path(conn.request_path) path = if path in ids do "posts/" <> path else path end conn |> put_resp_header("location", "https://jola.dev/" <> path) |> send_resp(:moved_permanently, "") |> halt() else conn end end defp strip_path("/" <> rest), do: rest defp strip_path(path), do: path end
SEO タグ
レイアウトは
conn.assigns を参照し、Open Graph / Twitter Card タグを挿入します。ブログ投稿は自動で次のタグを取得します:
<meta property="og:type" content={if @conn.assigns[:post], do: "article", else: "website"} /> <%= if post = @conn.assigns[:post] do %> <meta property="article:published_time" content={Date.to_iso8601(post.date)} /> <meta property="article:author" content="https://jola.dev/about" /> <%= for tag <- post.tags do %> <meta property="article:tag" content={tag} /> <% end %> <% end %>
Twitter Card と説明タグも同様のパターンで設定します。
また
llms.txt / llms-full.txt エンドポイントを追加し、AI システムがサイトを理解しやすいようにしています。これらはサイトマップロジックとほぼ同じです。
まとめ
このプロジェクトは意図的に軽量でありながら強力です:
- Phoenix + NimblePublisher = 高速・保守性の高いブログ
- GitHub Actions → Dokploy → Hetzner のゼロエフォートデプロイパイプライン
- (オプション)bunny.net をフロントに置いた CDN
Elixir 開発者で個人サイトをホストしたい方は、ぜひこのスタックを検討してください。すぐに本番環境で動作し、最小限の摩擦で拡張可能です。
リソース
- ソースコード:https://github.com/joladev/jola.dev
- Hetzner リファーラル(あなたが €20 を受け取り、私が €10):https://www.hetzner.com/cloud/
- Dokploy スポンサー:[リンク]
bunny.net の設定や Hetzner 上の Dokploy に関する詳細投稿を随時配信します。