Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 18 additions & 25 deletions lib/jola_dev/og_image.ex
Original file line number Diff line number Diff line change
@@ -1,41 +1,34 @@
defmodule JolaDev.OGImage do
@moduledoc """
Per-page Open Graph image lookup. All images are rendered at compile time
via `JolaDev.OGImage.Renderer` and baked into the `@baked` module
attribute, then served at runtime from `JolaDevWeb.OGImageController`.

See `JolaDev.OGImage.Renderer` for the rendering primitives.
Per-page Open Graph image lookup. All content from
`JolaDev.OGImage.Catalog` is rendered at compile time via
`JolaDev.OGImage.Renderer` and baked into the `@images` module
attribute, then served at runtime from `JolaDevWeb.Plugs.OGImage`.
"""

alias JolaDev.OGImage.Catalog
alias JolaDev.OGImage.Renderer

@dev_mode Application.compile_env!(:jola_dev, :og_image_dev_mode)
@baked if not @dev_mode,
do:
Map.new(Renderer.all_slugs(), fn slug ->
{title, description} = Renderer.content_for(slug)
{slug, Renderer.generate_bytes(title, description)}
end)
@images if not @dev_mode,
do:
Map.new(Catalog.all_slugs(), fn slug ->
{title, description} = Catalog.content_for(slug)
{slug, Renderer.generate_bytes(title, description)}
end)

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

@doc """
Returns `{:ok, png_bytes}` for a known slug, `:error` otherwise.
"""
def bytes_for(slug) do
if @baked do
Map.fetch(@baked, slug)
def image_for(slug) do
if @images do
Map.fetch(@images, slug)
else
case Renderer.content_for(slug) do
{title, description} ->
{:ok, Renderer.generate_bytes(title, description)}

:error ->
:error
with {title, description} <- Catalog.content_for(slug) do
{:ok, Renderer.generate_bytes(title, description)}
end
end
end
Expand Down
53 changes: 53 additions & 0 deletions lib/jola_dev/og_image/catalog.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
defmodule JolaDev.OGImage.Catalog do
@moduledoc """
This module owns the content used to render the OG images,
including the full list of matching slugs.
"""

alias JolaDev.Blog

@static_content %{
"home" =>
{"Johanna Larsson",
"Software engineer, engineering leader, writer, and speaker with many years of experience building products and leading teams."},
"about" =>
{"About",
"About Johanna Larsson: software engineer, engineering leader, writer, and speaker with many years of experience."},
"projects" =>
{"Projects",
"Open source projects by Johanna Larsson, including HexDiff, ElixirEvents, and more."},
"talks" =>
{"Talks",
"Conference talks and presentations by Johanna Larsson on Elixir, distributed systems, and engineering leadership."},
"posts" =>
{"Blog",
"Blog posts by Johanna Larsson on software engineering, Elixir, and engineering leadership."}
}

def content_for(slug) when is_map_key(@static_content, slug),
do: Map.fetch!(@static_content, slug)

def content_for("posts/tag/" <> tag) do
if tag in Blog.all_tags() do
{~s(Posts tagged "#{tag}"), "Blog posts by Johanna Larsson tagged with #{tag}."}
else
:error
end
end

def content_for("posts/" <> id) do
case Blog.find_by_id(id) do
nil -> :error
post -> {post.title, post.description}
end
end

def content_for(_), do: :error

def all_slugs do
static = Map.keys(@static_content)
posts = Enum.map(Blog.all_posts(), &"posts/#{&1.id}")
tags = Enum.map(Blog.all_tags(), &"posts/tag/#{&1}")
static ++ posts ++ tags
end
end
56 changes: 1 addition & 55 deletions lib/jola_dev/og_image/renderer.ex
Original file line number Diff line number Diff line change
@@ -1,17 +1,8 @@
defmodule JolaDev.OGImage.Renderer do
@moduledoc """
Pure image-rendering primitives for OGImage. Kept in a sibling module so
that `JolaDev.OGImage` can call into it from a compile-time module
attribute (Elixir module attributes can't call functions defined in the
same module they're being compiled into).

Requires Inter installed system-wide so fontconfig can find it
(`brew install --cask font-inter` on macOS, `fonts-inter` apt package
in the Docker builder stage).
Pure image-rendering primitives for OGImage.
"""

alias JolaDev.Blog

@width 1200
@height 630
@padding 80
Expand All @@ -30,57 +21,12 @@ defmodule JolaDev.OGImage.Renderer do

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

@static_content %{
"home" =>
{"Johanna Larsson",
"Software engineer, engineering leader, writer, and speaker with many years of experience building products and leading teams."},
"about" =>
{"About",
"About Johanna Larsson: software engineer, engineering leader, writer, and speaker with many years of experience."},
"projects" =>
{"Projects",
"Open source projects by Johanna Larsson, including HexDiff, ElixirEvents, and more."},
"talks" =>
{"Talks",
"Conference talks and presentations by Johanna Larsson on Elixir, distributed systems, and engineering leadership."},
"posts" =>
{"Blog",
"Blog posts by Johanna Larsson on software engineering, Elixir, and engineering leadership."}
}

def generate_bytes(title, description) when is_binary(title) and is_binary(description) do
title
|> build_canvas(description)
|> Image.write!(:memory, suffix: ".png")
end

def content_for(slug) when is_map_key(@static_content, slug),
do: Map.fetch!(@static_content, slug)

def content_for("posts/tag/" <> tag) do
if tag in Blog.all_tags() do
{~s(Posts tagged "#{tag}"), "Blog posts by Johanna Larsson tagged with #{tag}."}
else
:error
end
end

def content_for("posts/" <> id) do
case Blog.find_by_id(id) do
nil -> :error
post -> {post.title, post.description}
end
end

def content_for(_), do: :error

def all_slugs do
static = Map.keys(@static_content)
posts = Enum.map(Blog.all_posts(), &"posts/#{&1.id}")
tags = Enum.map(Blog.all_tags(), &"posts/tag/#{&1}")
static ++ posts ++ tags
end

defp build_canvas(title, description) do
canvas = Image.new!(@width, @height, color: @background)

Expand Down
27 changes: 0 additions & 27 deletions lib/jola_dev_web/controllers/og_image_controller.ex

This file was deleted.

1 change: 1 addition & 0 deletions lib/jola_dev_web/endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ defmodule JolaDevWeb.Endpoint do
gzip: Mix.env() == :prod,
only: JolaDevWeb.static_paths()

plug JolaDevWeb.Plugs.OGImage
plug JolaDevWeb.Plugs.BlogRedirect
plug JolaDevWeb.Plugs.StripTrailingSlash

Expand Down
38 changes: 38 additions & 0 deletions lib/jola_dev_web/plugs/og_image.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
defmodule JolaDevWeb.Plugs.OGImage do
@moduledoc """
Serves Open Graph preview images from `JolaDev.OGImage`. Intercepts any
`/images/og/<slug>.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.
"""

@behaviour Plug

import Plug.Conn

@dev_mode Application.compile_env!(:jola_dev, :og_image_dev_mode)
@cache_control if @dev_mode, do: "public, max-age=0", else: "public, max-age=31536000"

@impl Plug
def init(opts), do: opts

@impl Plug
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
end

def call(conn, _), do: conn
end
2 changes: 0 additions & 2 deletions lib/jola_dev_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ defmodule JolaDevWeb.Router do
get "/posts", BlogController, :index
get "/posts/tag/:tag", BlogController, :tag
get "/posts/:id", BlogController, :show

get "/images/og/*slug", OGImageController, :show
end

scope "/", JolaDevWeb do
Expand Down
70 changes: 70 additions & 0 deletions test/jola_dev/og_image/catalog_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
defmodule JolaDev.OGImage.CatalogTest do
use ExUnit.Case, async: true
alias JolaDev.OGImage.Catalog

describe "content_for/1" do
test "returns {title, description} for each static slug" do
for slug <- ~w(home about projects talks posts) do
assert {title, description} = Catalog.content_for(slug)
assert is_binary(title)
assert is_binary(description)
end
end

test "returns post title and description for a known post slug" do
post = List.first(JolaDev.Blog.all_posts())

assert {title, description} = Catalog.content_for("posts/#{post.id}")
assert title == post.title
assert description == post.description
end

test "returns tag title and description for a known tag slug" do
tag = List.first(JolaDev.Blog.all_tags())

assert {title, description} = Catalog.content_for("posts/tag/#{tag}")
assert title =~ tag
assert description =~ tag
end

test "returns :error for an unknown post slug" do
assert Catalog.content_for("posts/this-does-not-exist") == :error
end

test "returns :error for an unknown tag slug" do
assert Catalog.content_for("posts/tag/this-tag-does-not-exist") == :error
end

test "returns :error for an unknown slug" do
assert Catalog.content_for("random/path") == :error
end
end

describe "all_slugs/0" do
test "includes the static pages" do
slugs = Catalog.all_slugs()

for static <- ~w(home about projects talks posts) do
assert static in slugs
end
end

test "includes every blog post" do
slugs = Catalog.all_slugs()
post_slugs = Enum.map(JolaDev.Blog.all_posts(), &"posts/#{&1.id}")

for slug <- post_slugs do
assert slug in slugs
end
end

test "includes every tag" do
slugs = Catalog.all_slugs()
tag_slugs = Enum.map(JolaDev.Blog.all_tags(), &"posts/tag/#{&1}")

for slug <- tag_slugs do
assert slug in slugs
end
end
end
end
Loading
Loading