Skip to content

Commit 143f89c

Browse files
authored
Various OG image improvements (#40)
Some better naming and switching from controller to plug, that just feels a lot better.
1 parent d8bf097 commit 143f89c

12 files changed

Lines changed: 246 additions & 222 deletions

File tree

lib/jola_dev/og_image.ex

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,34 @@
11
defmodule JolaDev.OGImage do
22
@moduledoc """
3-
Per-page Open Graph image lookup. All images are rendered at compile time
4-
via `JolaDev.OGImage.Renderer` and baked into the `@baked` module
5-
attribute, then served at runtime from `JolaDevWeb.OGImageController`.
6-
7-
See `JolaDev.OGImage.Renderer` for the rendering primitives.
3+
Per-page Open Graph image lookup. All content from
4+
`JolaDev.OGImage.Catalog` is rendered at compile time via
5+
`JolaDev.OGImage.Renderer` and baked into the `@images` module
6+
attribute, then served at runtime from `JolaDevWeb.Plugs.OGImage`.
87
"""
98

9+
alias JolaDev.OGImage.Catalog
1010
alias JolaDev.OGImage.Renderer
1111

1212
@dev_mode Application.compile_env!(:jola_dev, :og_image_dev_mode)
13-
@baked if not @dev_mode,
14-
do:
15-
Map.new(Renderer.all_slugs(), fn slug ->
16-
{title, description} = Renderer.content_for(slug)
17-
{slug, Renderer.generate_bytes(title, description)}
18-
end)
13+
@images if not @dev_mode,
14+
do:
15+
Map.new(Catalog.all_slugs(), fn slug ->
16+
{title, description} = Catalog.content_for(slug)
17+
{slug, Renderer.generate_bytes(title, description)}
18+
end)
1919

2020
@doc """
21-
Returns the public asset path for a slug's OG image. Used by controllers,
22-
the layout, and the SEO helper so the path scheme stays consistent.
21+
Returns the public asset path for a slug's OG image. Used so the
22+
path scheme stays consistent.
2323
"""
2424
def path_for(slug) when is_binary(slug), do: "/images/og/#{slug}.png"
2525

26-
@doc """
27-
Returns `{:ok, png_bytes}` for a known slug, `:error` otherwise.
28-
"""
29-
def bytes_for(slug) do
30-
if @baked do
31-
Map.fetch(@baked, slug)
26+
def image_for(slug) do
27+
if @images do
28+
Map.fetch(@images, slug)
3229
else
33-
case Renderer.content_for(slug) do
34-
{title, description} ->
35-
{:ok, Renderer.generate_bytes(title, description)}
36-
37-
:error ->
38-
:error
30+
with {title, description} <- Catalog.content_for(slug) do
31+
{:ok, Renderer.generate_bytes(title, description)}
3932
end
4033
end
4134
end

lib/jola_dev/og_image/catalog.ex

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
defmodule JolaDev.OGImage.Catalog do
2+
@moduledoc """
3+
This module owns the content used to render the OG images,
4+
including the full list of matching slugs.
5+
"""
6+
7+
alias JolaDev.Blog
8+
9+
@static_content %{
10+
"home" =>
11+
{"Johanna Larsson",
12+
"Software engineer, engineering leader, writer, and speaker with many years of experience building products and leading teams."},
13+
"about" =>
14+
{"About",
15+
"About Johanna Larsson: software engineer, engineering leader, writer, and speaker with many years of experience."},
16+
"projects" =>
17+
{"Projects",
18+
"Open source projects by Johanna Larsson, including HexDiff, ElixirEvents, and more."},
19+
"talks" =>
20+
{"Talks",
21+
"Conference talks and presentations by Johanna Larsson on Elixir, distributed systems, and engineering leadership."},
22+
"posts" =>
23+
{"Blog",
24+
"Blog posts by Johanna Larsson on software engineering, Elixir, and engineering leadership."}
25+
}
26+
27+
def content_for(slug) when is_map_key(@static_content, slug),
28+
do: Map.fetch!(@static_content, slug)
29+
30+
def content_for("posts/tag/" <> tag) do
31+
if tag in Blog.all_tags() do
32+
{~s(Posts tagged "#{tag}"), "Blog posts by Johanna Larsson tagged with #{tag}."}
33+
else
34+
:error
35+
end
36+
end
37+
38+
def content_for("posts/" <> id) do
39+
case Blog.find_by_id(id) do
40+
nil -> :error
41+
post -> {post.title, post.description}
42+
end
43+
end
44+
45+
def content_for(_), do: :error
46+
47+
def all_slugs do
48+
static = Map.keys(@static_content)
49+
posts = Enum.map(Blog.all_posts(), &"posts/#{&1.id}")
50+
tags = Enum.map(Blog.all_tags(), &"posts/tag/#{&1}")
51+
static ++ posts ++ tags
52+
end
53+
end

lib/jola_dev/og_image/renderer.ex

Lines changed: 1 addition & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
11
defmodule JolaDev.OGImage.Renderer do
22
@moduledoc """
3-
Pure image-rendering primitives for OGImage. Kept in a sibling module so
4-
that `JolaDev.OGImage` can call into it from a compile-time module
5-
attribute (Elixir module attributes can't call functions defined in the
6-
same module they're being compiled into).
7-
8-
Requires Inter installed system-wide so fontconfig can find it
9-
(`brew install --cask font-inter` on macOS, `fonts-inter` apt package
10-
in the Docker builder stage).
3+
Pure image-rendering primitives for OGImage.
114
"""
125

13-
alias JolaDev.Blog
14-
156
@width 1200
167
@height 630
178
@padding 80
@@ -30,57 +21,12 @@ defmodule JolaDev.OGImage.Renderer do
3021

3122
@logo_path "priv/static/images/logo.png"
3223

33-
@static_content %{
34-
"home" =>
35-
{"Johanna Larsson",
36-
"Software engineer, engineering leader, writer, and speaker with many years of experience building products and leading teams."},
37-
"about" =>
38-
{"About",
39-
"About Johanna Larsson: software engineer, engineering leader, writer, and speaker with many years of experience."},
40-
"projects" =>
41-
{"Projects",
42-
"Open source projects by Johanna Larsson, including HexDiff, ElixirEvents, and more."},
43-
"talks" =>
44-
{"Talks",
45-
"Conference talks and presentations by Johanna Larsson on Elixir, distributed systems, and engineering leadership."},
46-
"posts" =>
47-
{"Blog",
48-
"Blog posts by Johanna Larsson on software engineering, Elixir, and engineering leadership."}
49-
}
50-
5124
def generate_bytes(title, description) when is_binary(title) and is_binary(description) do
5225
title
5326
|> build_canvas(description)
5427
|> Image.write!(:memory, suffix: ".png")
5528
end
5629

57-
def content_for(slug) when is_map_key(@static_content, slug),
58-
do: Map.fetch!(@static_content, slug)
59-
60-
def content_for("posts/tag/" <> tag) do
61-
if tag in Blog.all_tags() do
62-
{~s(Posts tagged "#{tag}"), "Blog posts by Johanna Larsson tagged with #{tag}."}
63-
else
64-
:error
65-
end
66-
end
67-
68-
def content_for("posts/" <> id) do
69-
case Blog.find_by_id(id) do
70-
nil -> :error
71-
post -> {post.title, post.description}
72-
end
73-
end
74-
75-
def content_for(_), do: :error
76-
77-
def all_slugs do
78-
static = Map.keys(@static_content)
79-
posts = Enum.map(Blog.all_posts(), &"posts/#{&1.id}")
80-
tags = Enum.map(Blog.all_tags(), &"posts/tag/#{&1}")
81-
static ++ posts ++ tags
82-
end
83-
8430
defp build_canvas(title, description) do
8531
canvas = Image.new!(@width, @height, color: @background)
8632

lib/jola_dev_web/controllers/og_image_controller.ex

Lines changed: 0 additions & 27 deletions
This file was deleted.

lib/jola_dev_web/endpoint.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ defmodule JolaDevWeb.Endpoint do
3030
gzip: Mix.env() == :prod,
3131
only: JolaDevWeb.static_paths()
3232

33+
plug JolaDevWeb.Plugs.OGImage
3334
plug JolaDevWeb.Plugs.BlogRedirect
3435
plug JolaDevWeb.Plugs.StripTrailingSlash
3536

lib/jola_dev_web/plugs/og_image.ex

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
defmodule JolaDevWeb.Plugs.OGImage do
2+
@moduledoc """
3+
Serves Open Graph preview images from `JolaDev.OGImage`. Intercepts any
4+
`/images/og/<slug>.png` request, looks up the baked PNG bytes, and sends
5+
them with appropriate cache headers. Falls through for unknown slugs so
6+
Phoenix's router can render a 404.
7+
"""
8+
9+
@behaviour Plug
10+
11+
import Plug.Conn
12+
13+
@dev_mode Application.compile_env!(:jola_dev, :og_image_dev_mode)
14+
@cache_control if @dev_mode, do: "public, max-age=0", else: "public, max-age=31536000"
15+
16+
@impl Plug
17+
def init(opts), do: opts
18+
19+
@impl Plug
20+
def call(%Plug.Conn{request_path: "/images/og/" <> rest} = conn, _) do
21+
slug = String.replace_suffix(rest, ".png", "")
22+
23+
case JolaDev.OGImage.image_for(slug) do
24+
{:ok, bytes} ->
25+
conn
26+
|> put_resp_content_type("image/png")
27+
|> put_resp_header("cache-control", @cache_control)
28+
|> send_resp(200, bytes)
29+
|> halt()
30+
31+
:error ->
32+
# Let the request fall through so Phoenix handles the 404
33+
conn
34+
end
35+
end
36+
37+
def call(conn, _), do: conn
38+
end

lib/jola_dev_web/router.ex

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,6 @@ defmodule JolaDevWeb.Router do
4242
get "/posts", BlogController, :index
4343
get "/posts/tag/:tag", BlogController, :tag
4444
get "/posts/:id", BlogController, :show
45-
46-
get "/images/og/*slug", OGImageController, :show
4745
end
4846

4947
scope "/", JolaDevWeb do
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
defmodule JolaDev.OGImage.CatalogTest do
2+
use ExUnit.Case, async: true
3+
alias JolaDev.OGImage.Catalog
4+
5+
describe "content_for/1" do
6+
test "returns {title, description} for each static slug" do
7+
for slug <- ~w(home about projects talks posts) do
8+
assert {title, description} = Catalog.content_for(slug)
9+
assert is_binary(title)
10+
assert is_binary(description)
11+
end
12+
end
13+
14+
test "returns post title and description for a known post slug" do
15+
post = List.first(JolaDev.Blog.all_posts())
16+
17+
assert {title, description} = Catalog.content_for("posts/#{post.id}")
18+
assert title == post.title
19+
assert description == post.description
20+
end
21+
22+
test "returns tag title and description for a known tag slug" do
23+
tag = List.first(JolaDev.Blog.all_tags())
24+
25+
assert {title, description} = Catalog.content_for("posts/tag/#{tag}")
26+
assert title =~ tag
27+
assert description =~ tag
28+
end
29+
30+
test "returns :error for an unknown post slug" do
31+
assert Catalog.content_for("posts/this-does-not-exist") == :error
32+
end
33+
34+
test "returns :error for an unknown tag slug" do
35+
assert Catalog.content_for("posts/tag/this-tag-does-not-exist") == :error
36+
end
37+
38+
test "returns :error for an unknown slug" do
39+
assert Catalog.content_for("random/path") == :error
40+
end
41+
end
42+
43+
describe "all_slugs/0" do
44+
test "includes the static pages" do
45+
slugs = Catalog.all_slugs()
46+
47+
for static <- ~w(home about projects talks posts) do
48+
assert static in slugs
49+
end
50+
end
51+
52+
test "includes every blog post" do
53+
slugs = Catalog.all_slugs()
54+
post_slugs = Enum.map(JolaDev.Blog.all_posts(), &"posts/#{&1.id}")
55+
56+
for slug <- post_slugs do
57+
assert slug in slugs
58+
end
59+
end
60+
61+
test "includes every tag" do
62+
slugs = Catalog.all_slugs()
63+
tag_slugs = Enum.map(JolaDev.Blog.all_tags(), &"posts/tag/#{&1}")
64+
65+
for slug <- tag_slugs do
66+
assert slug in slugs
67+
end
68+
end
69+
end
70+
end

0 commit comments

Comments
 (0)