defmodule SEO do
@external_resource "README.md"
@moduledoc "README.md"
|> File.read!()
|> String.split("<!-- MDOC !-->")
|> Enum.fetch!(1)
@doc """
Setup your defaults. Domains are mapped:
- `:site` -> `SEO.Site`
- `:open_graph` -> `SEO.OpenGraph`
- `:unfurl` -> `SEO.Unfurl`
- `:facebook` -> `SEO.Facebook`
- `:twitter` -> `SEO.Twitter`
- `:breadcrumb` -> `SEO.Breadcrumb`
For example:
```elixir
use SEO, [
site: SEO.Site.build(description: "My Blog of many words and infrequent posts", default_title: "Fanastic Site")
facebook: SEO.Facebook.build(app_id: "123")
]
```
"""
@typedoc "Attributes describing an item"
@type attrs :: struct() | map() | Keyword.t() | nil
@typedoc "Fallback attributes describing an item and configuration"
@type config :: struct() | map() | Keyword.t() | nil
defmacro __using__(opts) do
SEO.define_config(opts)
end
@doc false
def define_config(opts) do
quote location: :keep, bind_quoted: [opts: opts] do
@behaviour SEO.Config
@seo_options SEO.Config.validate!(opts)
@doc """
Get configuration for SEO.
config/0 will return all SEO config
config/1 with SEO domain atom will return that domain's config
"""
@impl SEO.Config
def config, do: @seo_options
@impl SEO.Config
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
conn={@conn}
config={MyAppWeb.SEO.config()}
page_title={assigns[:page_title]}
/>
</head>
```
Alternatively, you may selectively render components:
```heex
<head>
<%# With your SEO module's configuration %>
<SEO.OpenGraph.meta
config={MyAppWeb.SEO.config(:open_graph)}
item={SEO.OpenGraph.Build.build(SEO.item(@conn))}
/>
<%# Or with runtime configuration %>
<SEO.Twitter.meta
config={%{site_name: "Foo Fighters"}}
item={SEO.Twitter.Build.build(SEO.item(@conn))}
/>
<%# Or without configuration is fine too %>
<SEO.Unfurl.meta item={SEO.Unfurl.Build.build(SEO.item(@conn))} />
</head>
```
"""
attr :conn, Plug.Conn,
required: true,
doc:
"`Plug.Conn` for the request. Used for domain configs that are functions and to fetch the item."
attr :item, :any,
doc:
"Item to render that implements SEO protocols. `SEO.item(@conn)` will be used if not supplied."
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 or another module that implements `SEO.Config`"
attr :json_library, :atom,
default: nil,
doc:
"JSON library to use when rendering JSON. `config[:json_library]` will be used if not supplied."
def juice(assigns) do
assigns =
assigns
|> assign_new(:item, fn -> SEO.item(assigns[:conn]) end)
|> assign_configs(assigns[:config], assigns[:conn])
~H"""
<SEO.Site.meta config={@site_config} item={SEO.Site.Build.build(@item, @conn)} page_title={@page_title} />
<SEO.Unfurl.meta config={@unfurl_config} item={SEO.Unfurl.Build.build(@item, @conn)} />
<SEO.OpenGraph.meta config={@open_graph_config} item={SEO.OpenGraph.Build.build(@item, @conn)} />
<SEO.Twitter.meta config={@twitter_config} item={SEO.Twitter.Build.build(@item, @conn)} />
<SEO.Facebook.meta config={@facebook_config} item={SEO.Facebook.Build.build(@item, @conn)} />
<SEO.Breadcrumb.meta config={@breadcrumb_config} item={SEO.Breadcrumb.Build.build(@item, @conn)} json_library={@json_library} :if={@json_library} />
"""
end
defp assign_configs(assigns, mod, conn) when is_atom(mod) do
config =
case to_string(mod) do
"Elixir." <> _ -> mod.config()
"" -> %{}
end
assign_configs(assigns, config, conn)
end
defp assign_configs(assigns, config, conn) do
assigns
|> assign(:config, config)
|> assign(:json_library, assigns[:json_library] || config[:json_library])
|> assign_configs(conn)
end
@domains SEO.Config.domains()
defp assign_configs(assigns, conn) do
Enum.reduce(@domains, assigns, fn domain, assigns ->
assign_new(assigns, :"#{domain}_config", fn ->
get_domain_config(assigns[:config], domain, conn)
end)
end)
end
defp get_domain_config(config, domain, conn) do
case config[domain] do
nil -> %{}
domain_config when is_function(domain_config, 1) -> domain_config.(conn) || %{}
domain_config when is_function(domain_config, 0) -> domain_config.() || %{}
domain_config -> domain_config
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
@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] || conn.assigns[@key] || %{}
def item(%Phoenix.LiveView.Socket{} = socket), do: socket.assigns[@key] || %{}
@doc "The key used in the conn or socket to find the item"
def key, do: @key
end