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/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/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/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"] 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