diff --git a/app/lib/firehose_web/components/layouts.ex b/app/lib/firehose_web/components/layouts.ex index 6e33e2c..1b0a1f2 100644 --- a/app/lib/firehose_web/components/layouts.ex +++ b/app/lib/firehose_web/components/layouts.ex @@ -12,6 +12,27 @@ defmodule FirehoseWeb.Layouts do embed_templates "layouts/*" + @doc """ + Stores content in the connection for later rendering in layouts. + + Used to inject dynamic meta tags into the `
` section. + """ + def put_content_for(conn, key, content) do + content_for = Map.get(conn.assigns, :content_for, %{}) + new_content_for = Map.update(content_for, key, [content], &(&1 ++ [content])) + Plug.Conn.assign(conn, :content_for, new_content_for) + end + + @doc """ + Retrieves content stored via `put_content_for/3`. + + Can be called from HEEx templates with `get_content_for(assigns, :meta_tags)`. + """ + def get_content_for(assigns, key) do + content_for = assigns[:content_for] + if is_map(content_for), do: Map.get(content_for, key, []), else: [] + end + @doc """ Shows the flash group with standard titles and content. diff --git a/app/lib/firehose_web/components/layouts/root.html.heex b/app/lib/firehose_web/components/layouts/root.html.heex index d14828b..4147954 100644 --- a/app/lib/firehose_web/components/layouts/root.html.heex +++ b/app/lib/firehose_web/components/layouts/root.html.heex @@ -31,6 +31,16 @@ window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme)); })(); + <%= for meta <- get_content_for(assigns, :meta_tags) do %> + + + + + <%= if meta.og_image do %> + + <% end %> + + <% end %> {@inner_content} diff --git a/app/lib/firehose_web/controllers/blog_controller.ex b/app/lib/firehose_web/controllers/blog_controller.ex index 60ab50f..43cab46 100644 --- a/app/lib/firehose_web/controllers/blog_controller.ex +++ b/app/lib/firehose_web/controllers/blog_controller.ex @@ -8,7 +8,11 @@ defmodule FirehoseWeb.BlogController do page = parse_page(params["page"]) result = blog.paginate(page) - render(conn, :index, + meta = Blogex.SEO.meta_tags_for_blog(blog, FirehoseWeb.Endpoint.url()) + + conn + |> FirehoseWeb.Layouts.put_content_for(:meta_tags, meta) + |> render(:index, page_title: blog.title(), blog_title: blog.title(), blog_description: blog.description(), @@ -24,9 +28,14 @@ defmodule FirehoseWeb.BlogController do post = blog.get_post!(slug) visibility = Blogex.Post.visibility(post) - render(conn, :show, + meta = Blogex.SEO.meta_tags(post, FirehoseWeb.Endpoint.url(), blog) + + conn + |> FirehoseWeb.Layouts.put_content_for(:meta_tags, meta) + |> render(:show, page_title: post.title, post: post, + meta: meta, base_path: blog.base_path(), visibility: visibility, authenticated: !!(conn.assigns[:current_scope] && conn.assigns.current_scope.user) diff --git a/app/priv/blog/engineering/2026/03-17-why-firehose.md b/app/priv/blog/engineering/2026/03-17-why-firehose.md index b24d6b7..6b25c5b 100644 --- a/app/priv/blog/engineering/2026/03-17-why-firehose.md +++ b/app/priv/blog/engineering/2026/03-17-why-firehose.md @@ -3,6 +3,7 @@ author: "Willem van den Ende", tags: ~w(meta ai), description: "Why I built a separate personal blog, and what it has to do with drinking from the firehose.", + image: "/images/firehose-logo.png", published: true } --- diff --git a/app/test/firehose_web/controllers/blog_controller_test.exs b/app/test/firehose_web/controllers/blog_controller_test.exs index a449da2..f11bcd9 100644 --- a/app/test/firehose_web/controllers/blog_controller_test.exs +++ b/app/test/firehose_web/controllers/blog_controller_test.exs @@ -66,4 +66,64 @@ defmodule FirehoseWeb.BlogControllerTest do refute response =~ "post-status-banner" end end + + describe "GET /blog/:blog_id/:slug - Open Graph meta tags" do + test "meta tags include og_title", %{conn: conn} do + response = conn |> get(~p"/blog/engineering/hello-world") |> html_response(200) + + assert response =~ ~s( get(~p"/blog/engineering/hello-world") |> html_response(200) + + assert response =~ ~s( get(~p"/blog/engineering/hello-world") |> html_response(200) + + assert response =~ ~s( get(~p"/blog/engineering/hello-world") |> html_response(200) + + assert response =~ ~s( get(~p"/blog/engineering/why-firehose") |> html_response(200) + + assert response =~ ~s( get(~p"/blog/engineering/hello-world") |> html_response(200) + + assert response =~ ~s( get(~p"/blog/engineering") |> html_response(200) + + assert response =~ ~s( get(~p"/blog/engineering") |> html_response(200) + + assert response =~ ~s( get(~p"/blog/engineering") |> html_response(200) + + assert response =~ ~s( get("/blog/engineering/tag/elixir") |> html_response(200) + assert body =~ ~s(tagged "elixir") + assert body =~ "Engineering Blog" assert body =~ "Hello World" end - test "GET /blog/engineering/tag/:tag page shows filtered posts", %{conn: conn} do - body = goto_engineering_tag_page(conn, "phoenix") - assert body =~ "Hello World" - end - - test "GET /blog/engineering/tag/:tag page shows empty list for nonexistent tag", %{ - conn: conn - } do - conn_res = get(conn, "/blog/engineering/tag/nonexistent-tag") - assert html_response(conn_res, 200) =~ ~s(tagged "nonexistent-tag") + test "GET /blog/engineering/tag/:tag shows empty list for nonexistent tag", %{conn: conn} do + body = conn |> get("/blog/engineering/tag/nonexistent-tag") |> html_response(200) + assert body =~ ~s(tagged "nonexistent-tag") end end describe "release notes blog tags" do - test "GET /blog/releases/tag/:tag shows tag page with all posts", %{conn: conn} do - body = goto_releases_tag_page(conn, "release") + test "GET /blog/releases/tag/:tag shows tag page with filtered posts", %{conn: conn} do + body = conn |> get("/blog/releases/tag/release") |> html_response(200) + assert body =~ ~s(tagged "release") + assert body =~ "Release Notes" assert body =~ "v0.1.0 Released" end - test "GET /blog/releases/tag/:tag page shows filtered posts", %{conn: conn} do - conn_res = get(conn, "/blog/releases/tag/nonexistent-tag") - assert html_response(conn_res, 200) =~ ~s(tagged "nonexistent-tag") - end - end - - describe "tag URL pattern" do - test "tag URLs follow pattern /blog/:blog_id/tag/:tag for engineering blog", %{conn: conn} do - # Test that the tag route exists and works correctly - conn_res1 = get(conn, "/blog/engineering/tag/elixir") - assert html_response(conn_res1, 200) =~ ~s(tagged "elixir") - - conn_res2 = get(conn, "/blog/engineering/tag/phoenix") - assert html_response(conn_res2, 200) =~ ~s(tagged "phoenix") - end - - test "tag URLs follow pattern /blog/:blog_id/tag/:tag for releases blog", %{conn: conn} do - # Test that the tag route exists and works correctly - conn_res = get(conn, "/blog/releases/tag/release") - assert html_response(conn_res, 200) =~ ~s(tagged "release") - end - - test "nonexistent tags return 200 with empty post list", %{conn: conn} do - conn_res = get(conn, "/blog/engineering/tag/nonexistent-tag") - assert html_response(conn_res, 200) + test "GET /blog/releases/tag/:tag shows empty list for nonexistent tag", %{conn: conn} do + body = conn |> get("/blog/releases/tag/nonexistent-tag") |> html_response(200) + assert body =~ ~s(tagged "nonexistent-tag") end end describe "tag page structure" do - test "tag page has proper layout and back link", %{conn: conn} do - body = goto_engineering_tag_page(conn, "elixir") - + test "engineering tag page has proper layout and back link", %{conn: conn} do + body = conn |> get("/blog/engineering/tag/elixir") |> html_response(200) assert body =~ "Engineering Blog" assert body =~ ~s(tagged "elixir") assert body =~ "All posts" end test "release tag page has proper layout and back link", %{conn: conn} do - body = goto_releases_tag_page(conn, "release") - + body = conn |> get("/blog/releases/tag/release") |> html_response(200) assert body =~ "Release Notes" assert body =~ ~s(tagged "release") assert body =~ "All posts" diff --git a/app/test/firehose_web/controllers/blog_test.exs b/app/test/firehose_web/controllers/blog_test.exs index e9893b5..6826289 100644 --- a/app/test/firehose_web/controllers/blog_test.exs +++ b/app/test/firehose_web/controllers/blog_test.exs @@ -3,34 +3,35 @@ defmodule FirehoseWeb.BlogTest do use FirehoseWeb.ConnCase - defp visit_engineering_page(conn, suffix \\ "") do - path = "/blog/engineering" <> suffix + defp visit_blog_page(conn, blog_id, suffix \\ "") do + path = "/blog/#{blog_id}" <> suffix body = conn |> get(path) |> html_response(200) - assert body =~ "Engineering Blog" - assert body =~ "firehose" - body - end - - defp visit_engineering_path(conn, suffix) do - path = "/blog/engineering" <> suffix - body = conn |> get(path) |> html_response(200) - assert body =~ "firehose" body end describe "engineering blog (HTML)" do test "GET /blog/engineering returns HTML index with layout", %{conn: conn} do - visit_engineering_page(conn) + body = visit_blog_page(conn, "engineering") + assert body =~ "Engineering Blog" + assert body =~ "firehose" end test "GET /blog/engineering/:slug returns HTML post with layout", %{conn: conn} do - body = visit_engineering_path(conn, "/hello-world") + body = visit_blog_page(conn, "engineering", "/hello-world") assert body =~ "Hello World" end + end - test "GET /blog/engineering/tag/:tag returns HTML tag page", %{conn: conn} do - body = visit_engineering_path(conn, "/tag/elixir") - assert body =~ ~s(tagged "elixir") + describe "release notes blog (HTML)" do + test "GET /blog/releases returns HTML index", %{conn: conn} do + body = visit_blog_page(conn, "releases") + assert body =~ "Release Notes" + assert body =~ "v0.1.0 Released" + end + + test "GET /blog/releases/:slug returns HTML post", %{conn: conn} do + body = visit_blog_page(conn, "releases", "/v0-1-0") + assert body =~ "v0.1.0 Released" end end @@ -58,24 +59,6 @@ defmodule FirehoseWeb.BlogTest do end end - describe "release notes blog (HTML)" do - test "GET /blog/releases returns HTML index", %{conn: conn} do - body = conn |> get("/blog/releases") |> html_response(200) - assert body =~ "Release Notes" - assert body =~ "v0.1.0 Released" - end - - test "GET /blog/releases/:slug returns HTML post", %{conn: conn} do - body = conn |> get("/blog/releases/v0-1-0") |> html_response(200) - assert body =~ "v0.1.0 Released" - end - - test "GET /blog/releases/tag/:tag returns HTML tag page", %{conn: conn} do - body = conn |> get("/blog/releases/tag/elixir") |> html_response(200) - assert body =~ ~s(tagged "elixir") - end - end - describe "engineering blog (JSON API)" do test "GET /api/blog/engineering returns post index", %{conn: conn} do assert %{"blog" => "engineering", "posts" => posts} = diff --git a/app/test/firehose_web/controllers/user_session_controller_test.exs b/app/test/firehose_web/controllers/user_session_controller_test.exs index 1a2d053..25387e3 100644 --- a/app/test/firehose_web/controllers/user_session_controller_test.exs +++ b/app/test/firehose_web/controllers/user_session_controller_test.exs @@ -16,6 +16,13 @@ defmodule FirehoseWeb.UserSessionControllerTest do assert response =~ "Log in with email" end + test "renders login page with password mode", %{conn: conn} do + response = conn |> get(~p"/users/log-in?mode=password") |> html_response(200) + assert response =~ "Log in" + assert response =~ ~p"/users/register" + assert response =~ "Log in with email" + end + test "renders login page with email filled in (sudo mode)", %{conn: conn, user: user} do html = conn @@ -30,13 +37,6 @@ defmodule FirehoseWeb.UserSessionControllerTest do assert html =~ ~s( get(~p"/users/log-in?mode=password") |> html_response(200) - assert response =~ "Log in" - assert response =~ ~p"/users/register" - assert response =~ "Log in with email" - end end describe "GET /users/log-in/:token" do diff --git a/app/test/firehose_web/layouts_test.exs b/app/test/firehose_web/layouts_test.exs new file mode 100644 index 0000000..0371a4f --- /dev/null +++ b/app/test/firehose_web/layouts_test.exs @@ -0,0 +1,58 @@ +defmodule FirehoseWeb.LayoutsTest do + use ExUnit.Case, async: true + + alias FirehoseWeb.Layouts + + @conn %Plug.Conn{ + assigns: %{} + } + + describe "put_content_for/3" do + test "stores content under the given key" do + conn = put_content_for(@conn, :meta_tags, %{og_title: "Test"}) + + assert conn.assigns[:content_for][:meta_tags] == [%{og_title: "Test"}] + end + + test "appends multiple calls to the same key" do + conn = + @conn + |> put_content_for(:meta_tags, %{og_title: "First"}) + |> put_content_for(:meta_tags, %{og_title: "Second"}) + + assert length(conn.assigns[:content_for][:meta_tags]) == 2 + assert Enum.at(conn.assigns[:content_for][:meta_tags], 0).og_title == "First" + assert Enum.at(conn.assigns[:content_for][:meta_tags], 1).og_title == "Second" + end + + test "stores different keys independently" do + conn = + @conn + |> put_content_for(:meta_tags, %{og_title: "Meta"}) + |> put_content_for(:scripts, "%(script)") + + assert Map.has_key?(conn.assigns[:content_for], :meta_tags) + assert Map.has_key?(conn.assigns[:content_for], :scripts) + end + end + + describe "get_content_for/2" do + test "returns stored content for a key" do + content_for = %{meta_tags: [%{og_title: "Test"}]} + + assert Layouts.get_content_for(%{content_for: content_for}, :meta_tags) == + [%{og_title: "Test"}] + end + + test "returns empty list when key does not exist" do + assert Layouts.get_content_for(%{content_for: %{}}, :nonexistent) == [] + end + + test "returns empty list when content_for is nil" do + assert Layouts.get_content_for(%{content_for: nil}, :nonexistent) == [] + end + end + + # Re-export for use in tests + defp put_content_for(conn, key, content), do: Layouts.put_content_for(conn, key, content) +end diff --git a/blogex/lib/blogex/post.ex b/blogex/lib/blogex/post.ex index 3d6c4dd..d484912 100644 --- a/blogex/lib/blogex/post.ex +++ b/blogex/lib/blogex/post.ex @@ -28,6 +28,7 @@ defmodule Blogex.Post do :description, :date, :blog, + :image, tags: [], published: true ] @@ -39,6 +40,7 @@ defmodule Blogex.Post do body: String.t(), description: String.t(), date: Date.t(), + image: String.t() | nil, tags: [String.t()], blog: atom(), published: boolean() diff --git a/blogex/lib/blogex/seo.ex b/blogex/lib/blogex/seo.ex index 0982d82..2fd4e38 100644 --- a/blogex/lib/blogex/seo.ex +++ b/blogex/lib/blogex/seo.ex @@ -3,11 +3,33 @@ defmodule Blogex.SEO do SEO helpers for generating meta tags and sitemaps. """ + @doc """ + Returns a map of meta tag attributes for a blog listing page. + Useful for setting OpenGraph and Twitter card tags on blog index pages. + + + """ + def meta_tags_for_blog(blog_module, base_url) do + url = "#{base_url}#{blog_module.base_path()}" + + %{ + title: blog_module.title(), + description: blog_module.description(), + og_title: blog_module.title(), + og_description: blog_module.description(), + og_type: "website", + og_url: url, + og_image: nil, + twitter_card: "summary" + } + end + @doc """ Returns a map of meta tag attributes for a post. Useful for setting OpenGraph and Twitter card tags in your layout. + """ def meta_tags(post, base_url, blog_module) do url = "#{base_url}#{blog_module.base_path()}/#{post.id}" @@ -19,6 +41,7 @@ defmodule Blogex.SEO do og_description: post.description, og_type: "article", og_url: url, + og_image: if(post.image, do: "#{base_url}#{post.image}", else: nil), article_published_time: Date.to_iso8601(post.date), article_author: post.author, article_tags: post.tags, diff --git a/blogex/test/blogex/post_test.exs b/blogex/test/blogex/post_test.exs index 7db6787..bf996fc 100644 --- a/blogex/test/blogex/post_test.exs +++ b/blogex/test/blogex/post_test.exs @@ -58,6 +58,20 @@ defmodule Blogex.PostTest do assert post.published == false end + + test "preserves image from frontmatter" do + attrs = Map.put(valid_attrs(), :image, "/images/cover.png") + + post = Post.build("x/2026/01-01-x.md", attrs, "x
") + + assert post.image == "/images/cover.png" + end + + test "defaults image to nil when not in frontmatter" do + post = Post.build("x/2026/01-01-x.md", valid_attrs(), "x
") + + assert post.image == nil + end end defp valid_attrs do diff --git a/blogex/test/blogex/seo_test.exs b/blogex/test/blogex/seo_test.exs index f841950..0a803ec 100644 --- a/blogex/test/blogex/seo_test.exs +++ b/blogex/test/blogex/seo_test.exs @@ -6,6 +6,8 @@ defmodule Blogex.SEOTest do defmodule StubBlog do def base_path, do: "/blog/eng" + def title, do: "Engineering Blog" + def description, do: "Our engineering insights" end @base_url "https://example.com" @@ -38,6 +40,63 @@ defmodule Blogex.SEOTest do assert meta.article_published_time == "2026-06-15" assert meta.article_tags == ["a", "b"] end + + test "includes og_image when post has an image" do + post = build(image: "/images/cover.png") + + meta = SEO.meta_tags(post, @base_url, StubBlog) + + assert meta.og_image == "https://example.com/images/cover.png" + end + + test "og_image is nil when post has no image" do + post = build() + + meta = SEO.meta_tags(post, @base_url, StubBlog) + + assert meta.og_image == nil + end + + test "og_image uses absolute URL with base_url prefix" do + post = build(image: "/assets/post-hero.jpg") + + meta = SEO.meta_tags(post, "https://myapp.dev", StubBlog) + + assert meta.og_image == "https://myapp.dev/assets/post-hero.jpg" + end + end + + describe "meta_tags_for_blog/2" do + test "returns website type" do + meta = SEO.meta_tags_for_blog(StubBlog, @base_url) + + assert meta.og_type == "website" + end + + test "includes blog title and description" do + meta = SEO.meta_tags_for_blog(StubBlog, @base_url) + + assert meta.og_title == "Engineering Blog" + assert meta.og_description == "Our engineering insights" + end + + test "has no og_image for blog listing" do + meta = SEO.meta_tags_for_blog(StubBlog, @base_url) + + assert meta.og_image == nil + end + + test "builds correct blog URL" do + meta = SEO.meta_tags_for_blog(StubBlog, @base_url) + + assert meta.og_url == "https://example.com/blog/eng" + end + + test "uses summary twitter card for blog listing" do + meta = SEO.meta_tags_for_blog(StubBlog, @base_url) + + assert meta.twitter_card == "summary" + end end describe "sitemap/2" do