lib/seo.ex

defmodule SEO do
  @external_resource "README.md"
  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- MDOC !-->")
             |> Enum.fetch!(1)

  defmacro __using__(opts) do
    SEO.define_config(opts)
  end

  @doc false
  def define_config(opts) do
    quote location: :keep, bind_quoted: [opts: opts] do
      @seo_options SEO.Config.validate!(opts)

      @doc false
      def config, do: @seo_options

      @doc false
      def config(domain), do: config()[domain] || []
    end
  end

  use Phoenix.Component

  @doc """
  Provide SEO juice. Requires an item and passes the item through all available domains

  ```heex
  <head>
    <%# remove the Phoenix-generated <.live_title> component %>
    <%# and replace with SEO.juice component %>
    <SEO.juice config={MyAppWeb.SEO.config()} item={SEO.item(assigns)} page_title={assigns[:page_title]} />
  </head>
  ```

  Alternatively, you may selectively render components:

  ```heex
  <head>
    <%# With your SEO module's configuration %>
    <SEO.OpenGraph.meta item={SEO.OpenGraph.Build.build(SEO.item(assigns), MyAppWeb.SEO.config(:open_graph))} />

    <%# Or with runtime configuration %>
    <SEO.Twitter.meta item={SEO.Twitter.Build.build(SEO.item(assigns), %{site_name: "Foo Fighters"})} />

    <%# Or without configuration is fine too %>
    <SEO.Unfurl.meta item={SEO.Unfurl.Build.build(SEO.item(assigns))} />
  </head>
  ```
  """

  attr(:item, :any, required: true, doc: "Item to render that implements `SEO.Build`")
  attr(:page_title, :string, default: nil, doc: "Page Title. Overrides item's title if supplied")
  attr(:config, :any, default: nil, doc: "Configuration for your SEO module")

  def juice(assigns) do
    assigns = assign_configs(assigns, assigns[:config])

    ~H"""
    <SEO.Site.meta config={@site_config} item={SEO.Site.Build.build(@item)} page_title={@page_title} />
    <SEO.Unfurl.meta config={@unfurl_config} item={SEO.Unfurl.Build.build(@item)} />
    <SEO.OpenGraph.meta config={@open_graph_config} item={SEO.OpenGraph.Build.build(@item)} />
    <SEO.Twitter.meta config={@twitter_config} item={SEO.Twitter.Build.build(@item)} />
    <SEO.Facebook.meta config={@facebook_config} item={SEO.Facebook.Build.build(@item)} />
    <SEO.Breadcrumb.meta config={@breadcrumb_config} item={SEO.Breadcrumb.Build.build(@item)} json_library={@config[:json_library]} :if={@config[:json_library]} />
    """
  end

  @keys ~w[site unfurl open_graph twitter facebook breadcrumb]a
  @doc false
  def assign_configs(assigns, config) do
    Enum.reduce(@keys, assigns, fn domain, assigns ->
      assign_new(assigns, :"#{domain}_config", fn -> config && config[domain] end)
    end)
  end

  @key :seo

  @doc "Assign the SEO item from the Plug.Conn or LiveView Socket"
  @spec assign(Plug.Conn.t() | Phoenix.LiveView.Socket.t(), any()) ::
          Plug.Conn.t() | Phoenix.LiveView.Socket.t()
  def assign(conn_or_socket, item)

  def assign(%Plug.Conn{} = conn, item) do
    Plug.Conn.put_private(conn, @key, item)
  end

  def assign(%Phoenix.LiveView.Socket{} = socket, item) do
    Phoenix.Component.assign(socket, @key, item)
  end

  def key, do: @key

  @doc "Fetch the SEO item from the Plug.Conn or LiveView Socket"
  @spec item(Plug.Conn.t() | Phoenix.LiveView.Socket.t()) :: any()
  def item(conn_or_socket)
  def item(%Plug.Conn{} = conn), do: conn.private[@key] || []
  def item(%Phoenix.LiveView.Socket{} = socket), do: socket.assigns[@key] || []
end