Skip to main content

lib/og_mate.ex

defmodule OGMate do
  @moduledoc """
  Builds PNG images at compile time from a user-provided key list and
  content function. Always returns `{:ok, bytes}` from `image_for/1`.
  Unknown keys resolve to the pre-built default image.

  ## Example

      defmodule MyApp.OGImage do
        use OGMate,
          all_keys: ["home", "about"],
          content_for: MyApp.OGContent,
          theme: [
            background: "#0a0a0a",
            foreground: "#ffffff",
            font: "Inter",
            secondary: "#a3a3a3",
            logo: "priv/static/images/logo.png",
            site_name: "myapp.com"
          ],
          default: {"MyApp", "A brief site description."}
      end

      {:ok, png} = MyApp.OGImage.image_for("home")

  ## Custom Renderer

  Pass a `renderer:` module that exports `render(title, description)`:

      defmodule MyApp.OGRenderer do
        def render(title, description), do: {:ok, png}
      end

      defmodule MyApp.OGImage do
        use OGMate,
          all_keys: [...],
          content_for: ...,
          renderer: MyApp.OGRenderer,
          default: {...}
      end

  `:theme` and `:renderer` are mutually exclusive.

  See `OGMate.Options` for the full option contract, and the
  [Getting started guide](getting_started.md) for end-to-end setup.
  """

  # ── Using macro ──────────────────────────────────────────────────

  @doc false
  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      @before_compile OGMate
      @og_opts opts
    end
  end

  @doc false
  defmacro __before_compile__(env) do
    opts = Module.get_attribute(env.module, :og_opts)
    options = OGMate.Options.validate!(opts)

    {default_title, default_description} = options.default

    default_bytes =
      OGMate.__render__!(options.theme, options.renderer, default_title, default_description)

    images =
      unless options.dev_mode do
        Map.new(options.all_keys, fn key ->
          {title, desc} = options.content_for.content_for(key)
          {key, OGMate.__render__!(options.theme, options.renderer, title, desc)}
        end)
      end

    quote do
      @og_default unquote(Macro.escape(default_bytes))
      @og_images unquote(Macro.escape(images))
      @og_content_for unquote(options.content_for)
      @og_theme unquote(Macro.escape(options.theme))
      @og_renderer unquote(Macro.escape(options.renderer))

      @spec image_for(String.t()) :: {:ok, binary()}
      def image_for(key) do
        with :error <- lookup(key) do
          {:ok, @og_default}
        end
      end

      def default_image, do: @og_default

      if @og_images do
        defp lookup(key) do
          Map.fetch(@og_images, key)
        end
      else
        defp lookup(key) do
          with {title, desc} <- @og_content_for.content_for(key) do
            {:ok, OGMate.__render__!(@og_theme, @og_renderer, title, desc)}
          end
        end
      end
    end
  end

  @doc false
  def __render__!(theme, renderer, title, desc) do
    result =
      case {theme, renderer} do
        {%OGMate.Theme{} = t, nil} -> OGMate.Renderer.render(title, desc, t)
        {nil, mod} when is_atom(mod) -> mod.render(title, desc)
      end

    case result do
      {:ok, bytes} -> bytes
      {:error, reason} -> raise "OGMate render failed: #{inspect(reason)}"
    end
  end
end