Current section
Files
Jump to
Current section
Files
lib/phoenix_page_meta.ex
defmodule PhoenixPageMeta do
@moduledoc """
Per-page metadata for Phoenix LiveView apps: breadcrumbs, active-link state,
SEO meta tag rendering.
Each page is described by a `PhoenixPageMeta.PageMeta` struct, which a
LiveView returns from its `page_meta/2` callback. The library renders the SEO
meta tags, builds the breadcrumb trail, and resolves active-link state from
it.
## Setup
See `PhoenixPageMeta.PageMeta` for the struct and its configuration. In short:
# config/config.exs (all optional)
config :phoenix_page_meta,
site_name: "MyApp",
supported_locales: [:en, :es],
extra_fields: [icon: nil]
# in a LiveView
%PhoenixPageMeta.PageMeta{title: "Hello", path: "/hello"}
## In LiveViews
In `MyAppWeb.live_view/0`:
def live_view do
quote do
use Phoenix.LiveView, layout: ...
@behaviour PhoenixPageMeta.LiveView
import PhoenixPageMeta.LiveView, only: [assign_page_meta: 1]
end
end
In each LiveView:
defmodule MyAppWeb.SomeLive do
use MyAppWeb, :live_view
@impl PhoenixPageMeta.LiveView
def page_meta(_socket, _action) do
%PhoenixPageMeta.PageMeta{title: "Hello", path: "/hello"}
end
def mount(_params, _session, socket) do
{:ok, assign_page_meta(socket)}
end
end
See `PhoenixPageMeta.PageMeta`, `PhoenixPageMeta.Breadcrumb`,
`PhoenixPageMeta.Components.Breadcrumbs`, `PhoenixPageMeta.Components.MetaTags`,
`PhoenixPageMeta.Site`, and `PhoenixPageMeta.LiveView`.
> #### Deprecated: `use PhoenixPageMeta` {: .warning}
>
> Declaring a per-app PageMeta module with `use PhoenixPageMeta` + a
> hand-rolled `defstruct` is deprecated as of 0.2.0 and will be removed in a
> future release. Use the `PhoenixPageMeta.PageMeta` struct instead.
"""
@required_struct_fields [:title, :path, :parent]
@doc """
Injects the PhoenixPageMeta wiring into a project PageMeta module.
Adds `@behaviour PhoenixPageMeta.Site`, default implementations of
`base_url/0` and `lang_path/2` (both `defoverridable`), wrappers for
`breadcrumbs/1` and `active?/2,3` that match the project struct, and an
`@after_compile` hook that validates the struct has the required fields.
> #### Deprecated {: .warning}
>
> `use PhoenixPageMeta` together with a hand-rolled `defstruct` is deprecated
> as of 0.2.0. Prefer the prebuilt, config-driven `PhoenixPageMeta.PageMeta`
> struct, which needs no per-app boilerplate. This macro will be removed in a
> future release.
"""
@deprecated "Use the prebuilt `PhoenixPageMeta.PageMeta` struct instead — see CHANGELOG 0.2.0"
defmacro __using__(opts) do
quote do
require PhoenixPageMeta
PhoenixPageMeta.__setup__(unquote(opts))
end
end
@doc false
defmacro __setup__(opts) do
base_url_opt = Keyword.get(opts, :base_url)
quote do
@behaviour PhoenixPageMeta.Site
@phoenix_page_meta_base_url unquote(Macro.escape(base_url_opt))
@doc """
Builds a breadcrumb trail. See `PhoenixPageMeta.Breadcrumb.build/1`.
"""
def breadcrumbs(page_meta) when is_struct(page_meta, __MODULE__) do
PhoenixPageMeta.Breadcrumb.build(page_meta)
end
@doc """
Returns true if the link path matches this page or one of its ancestors.
See `PhoenixPageMeta.active?/3`.
"""
def active?(page_meta, link_path) when is_struct(page_meta, __MODULE__) do
PhoenixPageMeta.active?(page_meta, link_path)
end
def active?(page_meta, link_path, opts) when is_struct(page_meta, __MODULE__) do
PhoenixPageMeta.active?(page_meta, link_path, opts)
end
@impl PhoenixPageMeta.Site
def base_url do
PhoenixPageMeta.__resolve_base_url__(__MODULE__, @phoenix_page_meta_base_url)
end
@impl PhoenixPageMeta.Site
def lang_path(page_meta, locale) when is_struct(page_meta, __MODULE__) do
PhoenixPageMeta.__default_lang_path__(page_meta, locale)
end
defoverridable base_url: 0, lang_path: 2
@after_compile {PhoenixPageMeta, :__validate_struct__}
end
end
@doc """
Returns true if the given link path matches the current page or one of its
ancestor paths.
## Options
* `:exact` — when `true`, only matches the current page exactly. Default `false`.
* `:query` — when `true`, query strings are part of the comparison. When
`false` (default), they are stripped from both sides before matching.
## Matching rules
Without `:exact`, a link is active if its path equals the current path or is
a prefix followed by `/`. So `/locations` matches `/locations` and
`/locations/123`, but not `/location-foo`.
Most projects call this via the project module's wrapper (e.g.
`MyAppWeb.PageMeta.active?/2,3`), which restricts the input type via
`%MyAppWeb.PageMeta{}` pattern. The lib-level function accepts any struct.
"""
def active?(page_meta, link_path, opts \\ [])
def active?(page_meta, link_path, opts)
when is_struct(page_meta) and is_binary(link_path) and is_list(opts) do
keep_query? = Keyword.get(opts, :query, false)
exact? = Keyword.get(opts, :exact, false)
current = page_meta.path
{current, link_path} =
if keep_query?,
do: {current, link_path},
else: {strip_query(current), strip_query(link_path)}
cond do
exact? -> current == link_path
current == link_path -> true
true -> String.starts_with?(current, link_path <> "/")
end
end
@doc false
def __validate_struct__(env, bytecode) do
# Force-load the freshly compiled module so we can introspect its struct.
case Code.ensure_loaded(env.module) do
{:module, _} -> :ok
{:error, _} -> :code.load_binary(env.module, String.to_charlist(env.file), bytecode)
end
unless function_exported?(env.module, :__struct__, 0) do
raise CompileError,
file: env.file,
line: env.line,
description:
"PhoenixPageMeta: #{inspect(env.module)} must defstruct (with at least #{inspect(@required_struct_fields)})."
end
fields = env.module.__struct__() |> Map.keys()
missing = @required_struct_fields -- fields
if missing != [] do
raise CompileError,
file: env.file,
line: env.line,
description:
"PhoenixPageMeta: #{inspect(env.module)} is missing required struct fields: #{inspect(missing)}. Add them to defstruct."
end
:ok
end
@doc false
def __resolve_base_url__(_module, url) when is_binary(url), do: url
def __resolve_base_url__(_module, fun) when is_function(fun, 0), do: fun.()
def __resolve_base_url__(page_meta_module, nil) do
guessed = __guess_endpoint__(page_meta_module)
cond do
not Code.ensure_loaded?(guessed) ->
raise """
PhoenixPageMeta could not auto-detect a base URL for #{inspect(page_meta_module)}.
Tried `#{inspect(guessed)}` (sibling of your PageMeta module), but it
could not be loaded.
Pass it explicitly:
use PhoenixPageMeta, base_url: "https://example.com"
# or
use PhoenixPageMeta, base_url: &#{inspect(guessed)}.url/0
"""
not function_exported?(guessed, :url, 0) ->
raise """
PhoenixPageMeta found #{inspect(guessed)} but it does not export `url/0`.
Pass `base_url:` explicitly to `use PhoenixPageMeta`.
"""
true ->
guessed.url()
end
end
@doc false
def __guess_endpoint__(page_meta_module) do
page_meta_module
|> Module.split()
|> List.delete_at(-1)
|> Kernel.++(["Endpoint"])
|> Module.concat()
end
@doc false
def __default_lang_path__(page_meta, locale) when is_struct(page_meta) do
case String.split(page_meta.path, "/", trim: true) do
[_old_locale | rest] -> "/" <> Enum.join([to_string(locale) | rest], "/")
[] -> "/" <> to_string(locale)
end
end
# ---------------------------------------------------------------------------
# Config-driven resolution for `PhoenixPageMeta.PageMeta`
#
# The prebuilt struct is a single shared module, so the namespace-based
# endpoint guess used by `use PhoenixPageMeta` cannot apply. These helpers
# resolve site-wide values from `config :phoenix_page_meta` instead.
# ---------------------------------------------------------------------------
@doc false
def __config_base_url__ do
case Application.get_env(:phoenix_page_meta, :base_url) do
nil -> __config_endpoint__().url()
url when is_binary(url) -> url
fun when is_function(fun, 0) -> fun.()
{m, f, a} when is_atom(m) and is_atom(f) and is_list(a) -> apply(m, f, a)
end
end
@doc false
def __config_lang_path__(page_meta, locale) when is_struct(page_meta) do
case Application.get_env(:phoenix_page_meta, :lang_path) do
nil -> __default_lang_path__(page_meta, locale)
fun when is_function(fun, 2) -> fun.(page_meta, locale)
{m, f} when is_atom(m) and is_atom(f) -> apply(m, f, [page_meta, locale])
end
end
@doc false
def __config_endpoint__ do
case Application.get_env(:phoenix_page_meta, :endpoint) do
mod when is_atom(mod) and not is_nil(mod) -> mod
nil -> detect_endpoint!()
end
end
defp detect_endpoint! do
case :persistent_term.get({__MODULE__, :endpoint}, nil) do
nil ->
mod = scan_for_endpoint!()
:persistent_term.put({__MODULE__, :endpoint}, mod)
mod
mod ->
mod
end
end
defp scan_for_endpoint! do
endpoints =
for {mod, _file} <- :code.all_loaded(),
behaviours = mod.module_info(:attributes)[:behaviour] || [],
Phoenix.Endpoint in behaviours,
do: mod
case endpoints do
[mod] ->
mod
[] ->
raise """
PhoenixPageMeta.PageMeta could not auto-detect a Phoenix endpoint.
No loaded module implements `Phoenix.Endpoint`. Set one explicitly:
config :phoenix_page_meta, endpoint: MyAppWeb.Endpoint
# or
config :phoenix_page_meta, base_url: "https://example.com"
"""
many ->
raise """
PhoenixPageMeta.PageMeta found multiple Phoenix endpoints: #{inspect(many)}.
Pick one explicitly:
config :phoenix_page_meta, endpoint: MyAppWeb.Endpoint
# or
config :phoenix_page_meta, base_url: &MyAppWeb.Endpoint.url/0
"""
end
end
defp strip_query(path) do
case :binary.split(path, "?") do
[path, _] -> path
[path] -> path
end
end
end