OGI (Oh Gee)

Generates and serves OpenGraph Images using Typst.

Inspired by OG-Image but uses Typst instead of Chrome+Puppeteer, so you can add it directly to your Phoenix app.

Generates (beautiful?) share images like this one for my blog peterullrich.com

Installation

def deps do
  [
    {:ogi, "~> 0.2.2"}
  ]
end

Setup

You need these three things:

  1. A Typst template.
  2. A Phoenix Controller and Route.
  3. An og:image metatag in your <head> tag.

1. The Typst Template

LLMs are pretty good at generating those and you can test them quickly on typst.app/play

Make sure that your markup follows the best-practices of OpenGraph Images which are:

2. The Phoenix Controller and Route

Below is an example controller for serving OG Images for a blog post.

defmodule BlogWeb.ImageController do
  use BlogWeb, :controller

  alias Blog.Posts

  def show(conn, %{"id" => blog_id}) do
    post = Posts.get_post_by_id!(blog_id)
    assigns = [title: post.title]
    opts = [typst_opts: [root_dir: typst_root(), extra_fonts: [fonts_dir()]]]
    Ogi.render_image(conn, "#{blog_id}.png", typst_markup(), assigns, opts)
  end

  # these paths need to be called at runtime for releases
  defp typst_root, do: Application.app_dir(:blog, "priv/typst")
  defp fonts_dir, do: Path.join(typst_root(), "fonts")

  defp typst_markup do
    # Your Typst markup goes here.
    #
    # You can dynamically inline variables with:
    # Blog Title: <%= title %>
    #
    # Note: There is *no* @ before the variable, other than in HEEx templates!
    #
    # Example:
    """
    #set page(
      width: 1200pt,
      height: 630pt,
      margin: 64pt,
      fill: rgb("#0a1929")
    )
    #set text(size: 64pt, fill: white)

    #place(center + horizon)[Hello World!]
    #place(center + bottom)[By <%= author %>]
    """
  end
end

Then add this route to your router:

scope "/", BlogWeb do
  get "/og-image/:id", ImageController, :show
end

3. The Metatag

For adding dynamic Metatags, I recommend the Metatags library:

# In your Controller or LiveView serving the blog post, add this:
def handle_params(%{"id" => post_id}, _url, socket) do
  post = Posts.get_post_by_id!(post_id)

  socket =
    socket
    |> Metatags.put("og:title", post.title)
    |> Metatags.put("og:description", post.description)
    |> Metatags.put("og:image", url(~p"/og-image/#{post_id}"))

  {:ok, socket}
end

And that's it! You can test this by navigating to the route manually or by using a browser extension that previews OpenGraph information for a website.

Caveats

How to add Fonts and Images

Typst has access to system fonts, as well as fonts in directories specified by the extra_fonts option. If a font is unavailable, Typst will fallback to a serif font, unless you set fallback: false on a #text. In this case Typst will simply not render the text at all.

It is recommended to bundle fonts with your application. The example above places fonts in the priv/typst/fonts directory, and images and other file resources in priv/typst.

Don't use variable fonts

When adding fonts, make sure to add non-variable fonts (e.g. FiraSans-Bold.tff and FiraSans-SemiBold.tff etc.) instead of variable fonts (e.g. FiraSans.tff) because Typst does not support variable fonts (yet)! If you add a variable font, Typst will always render the same font weight.

Configuration

Currently, OGI supports the following configurations:

config :ogi,
  # Whether to cache rendered images or not (default: true)
  cache: true,
  # Where to store the cache. Defaults to a temporary folder.
  cache_dir: "./some/custom/dir",
  # An optional fallback image which is returned if the rendering
  # of the OG Image using Typst fails.
  fallback_image_path: "./priv/static/some-image.png"

Examples

You can find Livebooks with examples of various use-cases in the examples folder.

ToDo's