|
| 1 | +# Getting started |
| 2 | + |
| 3 | +TL;DR: define a content module mapping keys to titles and descriptions, point OGMate at it, mount a plug to serve `/images/og/<key>.png`, drop a meta tag in your layout. Images are built at compile time and served straight from memory. |
| 4 | + |
| 5 | +OG images are those preview cards you see when sharing links on social media. They don't help SEO but they make your links look intentional. OGMate handles the build pipeline, you provide the content. |
| 6 | + |
| 7 | +This walks through the full setup, framework-agnostic. Works inside a Phoenix endpoint, but Phoenix isn't required. |
| 8 | + |
| 9 | +## 1. Add the dependency |
| 10 | + |
| 11 | +```elixir |
| 12 | +def deps do |
| 13 | + [ |
| 14 | + {:og_mate, "~> 0.1.0"} |
| 15 | + ] |
| 16 | +end |
| 17 | +``` |
| 18 | + |
| 19 | +## 2. Content module |
| 20 | + |
| 21 | +Map URL slugs to `{title, description}`. Return `:error` for anything unknown, those fall through to the default image at runtime. |
| 22 | + |
| 23 | +```elixir |
| 24 | +defmodule MyApp.OGContent do |
| 25 | + @static_content %{ |
| 26 | + "home" => {"MyApp", "Welcome to MyApp."}, |
| 27 | + "about" => {"About", "What MyApp does."} |
| 28 | + } |
| 29 | + |
| 30 | + def content_for(key) when is_map_key(@static_content, key), |
| 31 | + do: Map.fetch!(@static_content, key) |
| 32 | + |
| 33 | + def content_for("posts/" <> id) do |
| 34 | + case MyApp.Blog.find_by_id(id) do |
| 35 | + nil -> :error |
| 36 | + post -> {post.title, post.description} |
| 37 | + end |
| 38 | + end |
| 39 | + |
| 40 | + def content_for(_), do: :error |
| 41 | +end |
| 42 | +``` |
| 43 | + |
| 44 | +Static map for fixed pages, pattern-matched clauses for dynamic content. The catch-all `:error` clause at the bottom handles anything the rest didn't. |
| 45 | + |
| 46 | +## 3. The OGMate module |
| 47 | + |
| 48 | +```elixir |
| 49 | +defmodule MyApp.OGImage do |
| 50 | + use OGMate, |
| 51 | + all_keys: |
| 52 | + ["home", "about"] ++ Enum.map(MyApp.Blog.all_posts(), &"posts/#{&1.id}"), |
| 53 | + content_for: MyApp.OGContent, |
| 54 | + theme: [ |
| 55 | + background: "#0a0a0a", |
| 56 | + foreground: "#ffffff", |
| 57 | + font: "Inter", |
| 58 | + secondary: "#a3a3a3", |
| 59 | + logo: "priv/static/images/logo.png", |
| 60 | + site_name: "myapp.com" |
| 61 | + ], |
| 62 | + default: {"MyApp", "Welcome to MyApp."}, |
| 63 | + dev_mode: Application.compile_env(:my_app, :og_image_dev_mode, false) |
| 64 | + |
| 65 | + def path_for(key) when is_binary(key), do: "/images/og/#{key}.png" |
| 66 | +end |
| 67 | +``` |
| 68 | + |
| 69 | +`all_keys` is computed at compile time. `MyApp.Blog.all_posts()` is a normal call to an already-compiled module, the result gets inlined as a literal list. New posts means a recompile, but Phoenix code reloading handles that for you in dev. |
| 70 | + |
| 71 | +`path_for/1` keeps URL building in one place so the plug and your templates stay in sync. |
| 72 | + |
| 73 | +## 4. The plug |
| 74 | + |
| 75 | +A small plug intercepts `/images/og/<key>.png` and serves the bytes. |
| 76 | + |
| 77 | +```elixir |
| 78 | +defmodule MyApp.Plugs.OGImage do |
| 79 | + @behaviour Plug |
| 80 | + import Plug.Conn |
| 81 | + |
| 82 | + @impl Plug |
| 83 | + def init(opts), do: opts |
| 84 | + |
| 85 | + @impl Plug |
| 86 | + def call(%Plug.Conn{request_path: "/images/og/" <> rest} = conn, _) do |
| 87 | + key = String.replace_suffix(rest, ".png", "") |
| 88 | + {:ok, bytes} = MyApp.OGImage.image_for(key) |
| 89 | + |
| 90 | + conn |
| 91 | + |> put_resp_content_type("image/png") |
| 92 | + |> put_resp_header("cache-control", "public, max-age=31536000") |
| 93 | + |> send_resp(200, bytes) |
| 94 | + |> halt() |
| 95 | + end |
| 96 | + |
| 97 | + def call(conn, _), do: conn |
| 98 | +end |
| 99 | +``` |
| 100 | + |
| 101 | +That `cache-control` is a year, which is fine because the URLs are content-keyed. If the content changes, the key changes. |
| 102 | + |
| 103 | +## 5. Mount the plug |
| 104 | + |
| 105 | +In Phoenix, drop it into the endpoint: |
| 106 | + |
| 107 | +```elixir |
| 108 | +# lib/my_app_web/endpoint.ex |
| 109 | +plug MyApp.Plugs.OGImage |
| 110 | +plug Plug.Static, ... |
| 111 | +``` |
| 112 | + |
| 113 | +Before `Plug.Static` so OG requests are caught first. Outside Phoenix, add it to your `Plug.Router`. |
| 114 | + |
| 115 | +## 6. Reference in your HTML |
| 116 | + |
| 117 | +```html |
| 118 | +<meta property="og:image" content={MyApp.OGImage.path_for(@og_key)} /> |
| 119 | +``` |
| 120 | + |
| 121 | +Set `@og_key` per route in your controller. `"home"` for the homepage, `"posts/#{post.id}"` for a post page. |
| 122 | + |
| 123 | +## 7. Dev mode |
| 124 | + |
| 125 | +Building everything at compile time is great for production. But rendering all your OG images on every save while you're iterating gets old fast. Set `dev_mode: true` and OGMate skips that at compile time, rendering lazily on each `image_for/1` call instead. New content shows up immediately, no recompile. |
| 126 | + |
| 127 | +```elixir |
| 128 | +# config/dev.exs |
| 129 | +config :my_app, og_image_dev_mode: true |
| 130 | +``` |
| 131 | + |
| 132 | +```elixir |
| 133 | +# config/prod.exs |
| 134 | +config :my_app, og_image_dev_mode: false |
| 135 | +``` |
| 136 | + |
| 137 | +The `Application.compile_env(:my_app, :og_image_dev_mode, false)` call in step 3 reads this at compile time. |
| 138 | + |
| 139 | +## See also |
| 140 | + |
| 141 | +- `OGMate`: main module reference |
| 142 | +- `OGMate.Theme`: theme field reference |
| 143 | +- `OGMate.Renderer`: default renderer (1200×630 PNG layout) |
0 commit comments