From 6027e54fd259d80b26ae16628bd845423c1c5434 Mon Sep 17 00:00:00 2001 From: Johanna Larsson Date: Tue, 12 May 2026 17:07:30 -0600 Subject: [PATCH 1/3] More OG image tweaks --- .../components/layouts/root.html.heex | 16 +++------------ .../controllers/blog_controller.ex | 9 +++------ .../controllers/page_controller.ex | 8 ++------ lib/jola_dev_web/helpers/og_image.ex | 14 +++++++++++++ lib/jola_dev_web/helpers/seo.ex | 2 +- test/jola_dev_web/helpers/seo_test.exs | 20 ++++++++++--------- 6 files changed, 34 insertions(+), 35 deletions(-) create mode 100644 lib/jola_dev_web/helpers/og_image.ex diff --git a/lib/jola_dev_web/components/layouts/root.html.heex b/lib/jola_dev_web/components/layouts/root.html.heex index b66b566..bcb1d78 100644 --- a/lib/jola_dev_web/components/layouts/root.html.heex +++ b/lib/jola_dev_web/components/layouts/root.html.heex @@ -27,12 +27,7 @@ @@ -63,12 +58,7 @@ /> <%= unless @conn.status && @conn.status >= 400 do %> @@ -98,7 +88,7 @@ - <%= for schema <- JolaDevWeb.SEO.json_ld(@conn) do %> + <%= for schema <- JolaDevWeb.Helpers.SEO.json_ld(@conn) do %> diff --git a/lib/jola_dev_web/controllers/blog_controller.ex b/lib/jola_dev_web/controllers/blog_controller.ex index e350410..8c45707 100644 --- a/lib/jola_dev_web/controllers/blog_controller.ex +++ b/lib/jola_dev_web/controllers/blog_controller.ex @@ -8,8 +8,7 @@ defmodule JolaDevWeb.BlogController do posts: posts, page_title: "Blog | jola.dev", meta_description: - "Blog posts by Johanna Larsson on software engineering, Elixir, and engineering leadership.", - og_image: JolaDev.OGImage.path_for("posts") + "Blog posts by Johanna Larsson on software engineering, Elixir, and engineering leadership." ) end @@ -27,8 +26,7 @@ defmodule JolaDevWeb.BlogController do tag: tag, noindex: true, page_title: "Posts tagged \"#{tag}\" | jola.dev", - meta_description: "Blog posts by Johanna Larsson tagged with #{tag}.", - og_image: JolaDev.OGImage.path_for("posts/tag/#{tag}") + meta_description: "Blog posts by Johanna Larsson tagged with #{tag}." ) end end @@ -39,8 +37,7 @@ defmodule JolaDevWeb.BlogController do post: post, related_posts: JolaDev.Blog.recent_posts(post), page_title: "#{post.title} | jola.dev", - meta_description: post.description, - og_image: JolaDev.OGImage.path_for("posts/#{post.id}") + meta_description: post.description ) else conn diff --git a/lib/jola_dev_web/controllers/page_controller.ex b/lib/jola_dev_web/controllers/page_controller.ex index bb9c122..2c23d04 100644 --- a/lib/jola_dev_web/controllers/page_controller.ex +++ b/lib/jola_dev_web/controllers/page_controller.ex @@ -8,7 +8,6 @@ defmodule JolaDevWeb.PageController do page_title: "Johanna Larsson — Software Engineer & Speaker", meta_description: "Johanna Larsson is a software engineer, engineering leader, writer, and speaker with many years of experience building products and leading teams.", - og_image: JolaDev.OGImage.path_for("home"), recent_posts: recent_posts ) end @@ -18,7 +17,6 @@ defmodule JolaDevWeb.PageController do page_title: "About | jola.dev", meta_description: "About Johanna Larsson — software engineer, engineering leader, writer, and speaker with many years of experience.", - og_image: JolaDev.OGImage.path_for("about"), page_type: :about ) end @@ -27,8 +25,7 @@ defmodule JolaDevWeb.PageController do render(conn, :projects, page_title: "Projects | jola.dev", meta_description: - "Open source projects by Johanna Larsson, including HexDiff, ElixirEvents, and more.", - og_image: JolaDev.OGImage.path_for("projects") + "Open source projects by Johanna Larsson, including HexDiff, ElixirEvents, and more." ) end @@ -36,8 +33,7 @@ defmodule JolaDevWeb.PageController do render(conn, :talks, page_title: "Talks | jola.dev", meta_description: - "Conference talks and presentations by Johanna Larsson on Elixir, distributed systems, and engineering leadership.", - og_image: JolaDev.OGImage.path_for("talks") + "Conference talks and presentations by Johanna Larsson on Elixir, distributed systems, and engineering leadership." ) end end diff --git a/lib/jola_dev_web/helpers/og_image.ex b/lib/jola_dev_web/helpers/og_image.ex new file mode 100644 index 0000000..27d8df4 --- /dev/null +++ b/lib/jola_dev_web/helpers/og_image.ex @@ -0,0 +1,14 @@ +defmodule JolaDevWeb.Helpers.OGImage do + @moduledoc """ + Builds URLs for OG images from assigns and falls back to request_path. + """ + use JolaDevWeb, :verified_routes + + def url_for(%{assigns: %{post: post}}), do: build("posts/#{post.id}") + def url_for(%{assigns: %{tag: tag}}), do: build("posts/tag/#{tag}") + def url_for(%{request_path: "/"}), do: build("home") + def url_for(%{request_path: "/" <> rest}), do: build(rest) + + defp build(slug), + do: unverified_url(JolaDevWeb.Endpoint, JolaDev.OGImage.path_for(slug)) +end diff --git a/lib/jola_dev_web/helpers/seo.ex b/lib/jola_dev_web/helpers/seo.ex index 533451c..0e21c81 100644 --- a/lib/jola_dev_web/helpers/seo.ex +++ b/lib/jola_dev_web/helpers/seo.ex @@ -1,4 +1,4 @@ -defmodule JolaDevWeb.SEO do +defmodule JolaDevWeb.Helpers.SEO do @moduledoc """ Generates JSON-LD structured data for search engines and AI systems. """ diff --git a/test/jola_dev_web/helpers/seo_test.exs b/test/jola_dev_web/helpers/seo_test.exs index 0424796..62203e3 100644 --- a/test/jola_dev_web/helpers/seo_test.exs +++ b/test/jola_dev_web/helpers/seo_test.exs @@ -1,10 +1,12 @@ -defmodule JolaDevWeb.SEOTest do +defmodule JolaDevWeb.Helpers.SEOTest do use JolaDevWeb.ConnCase, async: true + alias JolaDevWeb.Helpers.SEO + describe "json_ld/1" do test "includes WebSite schema on all pages", %{conn: conn} do conn = get(conn, ~p"/") - schemas = JolaDevWeb.SEO.json_ld(conn) + schemas = SEO.json_ld(conn) website = Enum.find(schemas, &(&1["@type"] == "WebSite")) assert website["name"] == "jola.dev" @@ -14,7 +16,7 @@ defmodule JolaDevWeb.SEOTest do test "includes BlogPosting schema on blog post pages", %{conn: conn} do post = List.first(JolaDev.Blog.all_posts()) conn = get(conn, ~p"/posts/#{post.id}") - schemas = JolaDevWeb.SEO.json_ld(conn) + schemas = SEO.json_ld(conn) blog_posting = Enum.find(schemas, &(&1["@type"] == "BlogPosting")) assert blog_posting["headline"] == post.title @@ -30,7 +32,7 @@ defmodule JolaDevWeb.SEOTest do test "includes ProfilePage schema on about page", %{conn: conn} do conn = get(conn, ~p"/about") - schemas = JolaDevWeb.SEO.json_ld(conn) + schemas = SEO.json_ld(conn) profile = Enum.find(schemas, &(&1["@type"] == "ProfilePage")) assert profile["mainEntity"]["@type"] == "Person" @@ -40,21 +42,21 @@ defmodule JolaDevWeb.SEOTest do test "does not include BlogPosting on non-post pages", %{conn: conn} do conn = get(conn, ~p"/projects") - schemas = JolaDevWeb.SEO.json_ld(conn) + schemas = SEO.json_ld(conn) refute Enum.any?(schemas, &(&1["@type"] == "BlogPosting")) end test "omits BreadcrumbList on the home page", %{conn: conn} do conn = get(conn, ~p"/") - schemas = JolaDevWeb.SEO.json_ld(conn) + schemas = SEO.json_ld(conn) refute Enum.any?(schemas, &(&1["@type"] == "BreadcrumbList")) end test "includes BreadcrumbList on /about", %{conn: conn} do conn = get(conn, ~p"/about") - schemas = JolaDevWeb.SEO.json_ld(conn) + schemas = SEO.json_ld(conn) breadcrumb = Enum.find(schemas, &(&1["@type"] == "BreadcrumbList")) @@ -77,7 +79,7 @@ defmodule JolaDevWeb.SEOTest do test "includes BreadcrumbList with post title on post pages", %{conn: conn} do post = List.first(JolaDev.Blog.all_posts()) conn = get(conn, ~p"/posts/#{post.id}") - schemas = JolaDevWeb.SEO.json_ld(conn) + schemas = SEO.json_ld(conn) breadcrumb = Enum.find(schemas, &(&1["@type"] == "BreadcrumbList")) items = breadcrumb["itemListElement"] @@ -90,7 +92,7 @@ defmodule JolaDevWeb.SEOTest do test "includes BreadcrumbList with tag on tag pages", %{conn: conn} do tag = "elixir" conn = get(conn, ~p"/posts/tag/#{tag}") - schemas = JolaDevWeb.SEO.json_ld(conn) + schemas = SEO.json_ld(conn) breadcrumb = Enum.find(schemas, &(&1["@type"] == "BreadcrumbList")) items = breadcrumb["itemListElement"] From c24fc8edb6660af5c54dbafd97a9df4c6fc10944 Mon Sep 17 00:00:00 2001 From: Johanna Larsson Date: Tue, 12 May 2026 17:46:44 -0600 Subject: [PATCH 2/3] try to maintain the og images --- test/jola_dev_web/helpers/og_image_test.exs | 50 +++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 test/jola_dev_web/helpers/og_image_test.exs diff --git a/test/jola_dev_web/helpers/og_image_test.exs b/test/jola_dev_web/helpers/og_image_test.exs new file mode 100644 index 0000000..9ec9f78 --- /dev/null +++ b/test/jola_dev_web/helpers/og_image_test.exs @@ -0,0 +1,50 @@ +defmodule JolaDevWeb.Helpers.OGImageTest do + use JolaDevWeb.ConnCase, async: true + + @skip_plugs [ + JolaDevWeb.RssController, + JolaDevWeb.SitemapController, + JolaDevWeb.LlmsController, + Phoenix.LiveView.Plug, + Phoenix.LiveDashboard.Assets + ] + + test "every public page has a working OG image" do + for route <- JolaDevWeb.Router.__routes__(), + route.verb == :get, + route.plug not in @skip_plugs do + path = concrete_path(route.path) + page = get(build_conn(), path) + assert page.status == 200, "GET #{path} returned #{page.status}" + + og_url = og_image_url(page.resp_body, path) + og_path = URI.parse(og_url).path + og_conn = get(build_conn(), og_path) + + assert og_conn.status == 200, + "page #{path} declares og:image #{og_url} but it returned #{og_conn.status}. " <> + "Add a catalog entry for slug \"#{slug(og_path)}\"." + end + end + + defp og_image_url(body, path) do + case body + |> LazyHTML.from_document() + |> LazyHTML.query("meta[property='og:image']") + |> LazyHTML.attribute("content") do + [url] -> url + [] -> flunk("no og:image meta tag on #{path}") + urls -> flunk("multiple og:image meta tags on #{path}: #{inspect(urls)}") + end + end + + defp concrete_path("/posts/:id"), + do: "/posts/#{List.first(JolaDev.Blog.all_posts()).id}" + + defp concrete_path("/posts/tag/:tag"), + do: "/posts/tag/#{List.first(JolaDev.Blog.all_tags())}" + + defp concrete_path(path), do: path + + defp slug("/images/og/" <> rest), do: String.replace_suffix(rest, ".png", "") +end From 524d7c5fd7fb5a3c6671245fedd833ae30c1e31a Mon Sep 17 00:00:00 2001 From: Johanna Larsson Date: Tue, 12 May 2026 18:03:58 -0600 Subject: [PATCH 3/3] handle default image --- lib/jola_dev/og_image.ex | 15 +++++++ lib/jola_dev_web/plugs/og_image.ex | 22 ++++----- test/jola_dev/og_image_test.exs | 2 +- test/jola_dev_web/helpers/og_image_test.exs | 50 --------------------- test/jola_dev_web/plugs/og_image_test.exs | 6 +-- 5 files changed, 27 insertions(+), 68 deletions(-) delete mode 100644 test/jola_dev_web/helpers/og_image_test.exs diff --git a/lib/jola_dev/og_image.ex b/lib/jola_dev/og_image.ex index d719767..4c58307 100644 --- a/lib/jola_dev/og_image.ex +++ b/lib/jola_dev/og_image.ex @@ -17,6 +17,11 @@ defmodule JolaDev.OGImage do {slug, Renderer.generate_bytes(title, description)} end) + @default_image ( + {title, description} = Catalog.content_for("home") + Renderer.generate_bytes(title, description) + ) + @doc """ Returns the public asset path for a slug's OG image. Used so the path scheme stays consistent. @@ -24,6 +29,16 @@ defmodule JolaDev.OGImage do def path_for(slug) when is_binary(slug), do: "/images/og/#{slug}.png" def image_for(slug) do + with :error <- image_for_slug(slug) do + {:ok, @default_image} + end + end + + def default_image do + @default_image + end + + defp image_for_slug(slug) do if @images do Map.fetch(@images, slug) else diff --git a/lib/jola_dev_web/plugs/og_image.ex b/lib/jola_dev_web/plugs/og_image.ex index d8b4f80..0e69038 100644 --- a/lib/jola_dev_web/plugs/og_image.ex +++ b/lib/jola_dev_web/plugs/og_image.ex @@ -2,8 +2,7 @@ defmodule JolaDevWeb.Plugs.OGImage do @moduledoc """ Serves Open Graph preview images from `JolaDev.OGImage`. Intercepts any `/images/og/.png` request, looks up the baked PNG bytes, and sends - them with appropriate cache headers. Falls through for unknown slugs so - Phoenix's router can render a 404. + them with appropriate cache headers. """ @behaviour Plug @@ -20,18 +19,13 @@ defmodule JolaDevWeb.Plugs.OGImage do def call(%Plug.Conn{request_path: "/images/og/" <> rest} = conn, _) do slug = String.replace_suffix(rest, ".png", "") - case JolaDev.OGImage.image_for(slug) do - {:ok, bytes} -> - conn - |> put_resp_content_type("image/png") - |> put_resp_header("cache-control", @cache_control) - |> send_resp(200, bytes) - |> halt() - - :error -> - # Let the request fall through so Phoenix handles the 404 - conn - end + {:ok, bytes} = JolaDev.OGImage.image_for(slug) + + conn + |> put_resp_content_type("image/png") + |> put_resp_header("cache-control", @cache_control) + |> send_resp(200, bytes) + |> halt() end def call(conn, _), do: conn diff --git a/test/jola_dev/og_image_test.exs b/test/jola_dev/og_image_test.exs index c2c985a..6bcead4 100644 --- a/test/jola_dev/og_image_test.exs +++ b/test/jola_dev/og_image_test.exs @@ -31,7 +31,7 @@ defmodule JolaDev.OGImageTest do end test "returns :error for an unknown slug" do - assert OGImage.image_for("no-such-page") == :error + assert OGImage.image_for("no-such-page") == {:ok, OGImage.default_image()} end end end diff --git a/test/jola_dev_web/helpers/og_image_test.exs b/test/jola_dev_web/helpers/og_image_test.exs deleted file mode 100644 index 9ec9f78..0000000 --- a/test/jola_dev_web/helpers/og_image_test.exs +++ /dev/null @@ -1,50 +0,0 @@ -defmodule JolaDevWeb.Helpers.OGImageTest do - use JolaDevWeb.ConnCase, async: true - - @skip_plugs [ - JolaDevWeb.RssController, - JolaDevWeb.SitemapController, - JolaDevWeb.LlmsController, - Phoenix.LiveView.Plug, - Phoenix.LiveDashboard.Assets - ] - - test "every public page has a working OG image" do - for route <- JolaDevWeb.Router.__routes__(), - route.verb == :get, - route.plug not in @skip_plugs do - path = concrete_path(route.path) - page = get(build_conn(), path) - assert page.status == 200, "GET #{path} returned #{page.status}" - - og_url = og_image_url(page.resp_body, path) - og_path = URI.parse(og_url).path - og_conn = get(build_conn(), og_path) - - assert og_conn.status == 200, - "page #{path} declares og:image #{og_url} but it returned #{og_conn.status}. " <> - "Add a catalog entry for slug \"#{slug(og_path)}\"." - end - end - - defp og_image_url(body, path) do - case body - |> LazyHTML.from_document() - |> LazyHTML.query("meta[property='og:image']") - |> LazyHTML.attribute("content") do - [url] -> url - [] -> flunk("no og:image meta tag on #{path}") - urls -> flunk("multiple og:image meta tags on #{path}: #{inspect(urls)}") - end - end - - defp concrete_path("/posts/:id"), - do: "/posts/#{List.first(JolaDev.Blog.all_posts()).id}" - - defp concrete_path("/posts/tag/:tag"), - do: "/posts/tag/#{List.first(JolaDev.Blog.all_tags())}" - - defp concrete_path(path), do: path - - defp slug("/images/og/" <> rest), do: String.replace_suffix(rest, ".png", "") -end diff --git a/test/jola_dev_web/plugs/og_image_test.exs b/test/jola_dev_web/plugs/og_image_test.exs index b5978f6..a16808f 100644 --- a/test/jola_dev_web/plugs/og_image_test.exs +++ b/test/jola_dev_web/plugs/og_image_test.exs @@ -39,13 +39,13 @@ defmodule JolaDevWeb.Plugs.OGImageTest do assert <<137, "PNG\r\n", 26, "\n", _rest::binary>> = conn.resp_body end - test "falls through for an unknown OG slug", %{conn: conn} do + test "returns default image for an unknown OG slug", %{conn: conn} do original = Map.put(conn, :request_path, "/images/og/no-such-page.png") result = OGImage.call(original, []) - assert result == original - refute result.halted + assert result.status == 200 + assert <<137, "PNG\r\n", 26, "\n", _rest::binary>> = result.resp_body end test "passes through for paths outside /images/og/", %{conn: conn} do