We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Yesterday I shared the blog post detailing how I’ve built this blog. I felt the link preview was lacking without any image.
Sure, I could add header images to the blog post, but what if I could just dynamically generate them on the fly? Already optimized for Open Graph?
Luckily that was super easy using Image
library by the always diligent Kip Cole. What took me the longest was just figuring out what makes for a good preview image! I used the Method Draw SVG editor to design the SVG.
First, we’ll add Image
to the dependencies:
# mix.exs
defmodule MyBlog.MixProject do
use Mix.Project
# ...
def deps do
[
# ...
{:image, "~> 0.33"}
]
end
#...
end
Now we’ll update our Blog.Post
module to generate an Open Graph image that’s 1200 by 600 pixels.
# lib/my_blog/blog/post.ex
defmodule MyBlog.Blog.Post do
@enforce_keys [:id, :og_image, :title, :body, :description, :tags, :date]
defstruct [:id, :og_image, :title, :body, :description, :tags, :date]
def build(filename, attrs, body) do
[year, month_day_id] = filename |> Path.rootname() |> Path.split() |> Enum.take(-2)
[month, day, id] = String.split(month_day_id, "-", parts: 3)
date = Date.from_iso8601!("#{year}-#{month}-#{day}")
attrs = Map.put(attrs, :og_image, generate_og_image(year, filename, attrs.title, attrs.tags))
struct!(__MODULE__, [id: id, date: date, body: body] ++ Map.to_list(attrs))
end
defp generate_og_image(year, filename, title, tags) do
{filename, basename} = og_image_paths(year, filename)
{title_line_1, title_line_2} = og_image_title_lines(title)
tags = Enum.join(tags, ", ")
svg =
"""
<svg viewbox="0 0 1200 600" width="1200" height="600" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient y2="1" x2="1" y1="0.14844" x1="0.53125" id="gradient">
<stop offset="0" stop-opacity="0.99609" stop-color="#5b21b6"/>
<stop offset="0.99219" stop-opacity="0.97656" stop-color="#ff8300"/>
</linearGradient>
</defs>
<g>
<rect stroke="#000" height="800" width="1800" y="0" x="0" stroke-width="0" fill="url(#gradient)"/>
<text font-style="normal" font-weight="normal" xml:space="preserve" text-anchor="start" font-family="'Alumni Sans'" font-size="70" y="250" x="100" stroke-width="0" stroke="#000" fill="#f8fafc">#{title_line_1}</text>
<text font-style="normal" font-weight="normal" xml:space="preserve" text-anchor="start" font-family="'Alumni Sans'" font-size="70" y="350" x="100" stroke-width="0" stroke="#000" fill="#f8fafc">#{title_line_2}</text>
<text font-style="normal" font-weight="normal" xml:space="preserve" text-anchor="start" font-family="'Alumni Sans'" font-size="30" y="550" x="50" stroke-width="0" stroke="#000" fill="#f8fafc" opacity="0.5">By Dan Schultzer</text>
<text font-style="normal" font-weight="normal" xml:space="preserve" text-anchor="end" font-family="'Alumni Sans'" font-size="30" y="550" x="1150" stroke-width="0" stroke="#000" fill="#f8fafc" opacity="0.5">#{tags}</text>
</g>
</svg>
"""
write_og_image(filename, svg)
%{year: year, basename: basename}
end
defp og_image_paths(year, filename) do
[root_dir, file] = filename |> Path.rootname() |> String.split(Path.join("posts", year))
basename = Path.basename(file, ".md") <> ".open-graph.png"
filename = Path.join([root_dir, "static", "images", "posts", year, basename])
File.mkdir_p!(Path.dirname(filename))
{filename, basename}
end
@max_length 31
defp og_image_title_lines(title) do
title
|> String.split(" ")
|> Enum.reduce_while({"", ""}, fn word, {title_line_1, title_line_2} ->
cond do
String.length(title_line_1 <> " " <> word) <= @max_length -> {:cont, {title_line_1 <> " " <> word, title_line_2}}
String.length(title_line_2 <> " " <> word) <= (@max_length - 3) -> {:cont, {title_line_1, title_line_2 <> " " <> word}}
true -> {:halt, {title_line_1, title_line_2 <> "..."}}
end
end)
end
defp write_og_image(filename, svg) do
{image, _} = Vix.Vips.Operation.svgload_buffer!(svg)
Image.write!(image, filename)
end
end
There are a few things happening here:
-
Derive a filename based on the filename of the blog post with
.open-graph.png
as the extension -
Create
images/posts/:year/
folder path insidepriv/static/
- Split the title into two lines as SVG doesn’t support multiline text
- Truncate the second title line if it’s too long
- Generate an image using SVG and save it
Now all you need is to add the Open Graph metatags to <head>
for the pages with a post:
# lib/my_blog_web/components/layouts/root.html.heex
<%= if assigns[:post] do %>
# ...
<meta property="og:type" content="article" />
<meta property="og:image" content={url(~p"/images/posts/#{@post.og_image.year}/#{@post.og_image.basename}")} />
# ...
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content={url(~p"/images/posts/#{@post.og_image.year}/#{@post.og_image.basename}")} />
<% end %>
Since the images are autogenerated we should ignore them in .gitignore
so they don’t show up in the working tree:
# .gitignore
# Ignore assets that are produced by build tools.
/priv/static/assets/
/priv/static/images/posts/*/*.open-graph.png
You are done! Spin up the server with mix phx.server
and you should see images/posts/:year/*.open-graph.png
file(s) in priv/static/
there should be a generated Open Graph image looking something like this:
Wow, huh, guess this solidifies that Phoenix with NimblePublisher is an excellent choice for blogging.
Custom fonts
I’m using a font that doesn’t exist in the debian image I deploy and it ends up using a default system font. How do we fix that? Easy!
First, put your font in ./assets/fonts
. Now in your Dockerfile
, make sure the font is installed before RUN mix deps.compile
gets called:
# Install font used for generated images
COPY ./assets/fonts/AlumniSans-VariableFont_wght.ttf ./
RUN mkdir -p /usr/share/fonts/truetype/
RUN install -m644 AlumniSans-VariableFont_wght.ttf /usr/share/fonts/truetype/
RUN rm ./AlumniSans-VariableFont_wght.ttf
Performance
I wondered how much of a performance penalty I added to my build process by doing this. After all, I do generate an image for every single blog post. If it takes any significant amount of time then I would have to look into caching now.
Let’s figure out what the median duration run looks like! I’m adding this code to my MyApp.Blog.Post.build/3
function:
1..100 #=> 1..100
|> Enum.map(fn _ ->
start_time = System.monotonic_time()
generate_og_image(year, filename, attrs.title, attrs.tags)
System.monotonic_time() - start_time
end) #=> [...]
|> Enum.sort() #=> [...]
|> Enum.at(49) #=> 17326042
|> System.convert_time_unit(:native, :millisecond) #=> 17
|> dbg()
With a median duration of ~17ms
(and an average of ~20ms
) I don’t have to think about caching until I’m some hundred posts deep (and even then we’re only talking about seconds added to the build)!
Hi, I'm Dan Schultzer, I write in this blog, work a lot in Elixir, maintain several open source projects, and help companies streamline their development process