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.
## Setup
In your project's PageMeta module:
defmodule MyAppWeb.PageMeta do
use PhoenixPageMeta
@enforce_keys [:title, :path]
defstruct [
:title,
:path,
:breadcrumb_title,
:parent,
:description,
:og_image,
:json_ld,
:canonical_path,
:icon,
:skip_breadcrumb,
og_type: "website",
noindex: false,
supported_locales: [:en, :es]
]
end
That's it. No `config.exs` entry. The macro provides default implementations
of `base_url/0` (auto-detected from the module's namespace, e.g.
`MyAppWeb.PageMeta` → `MyAppWeb.Endpoint.url()`) and `lang_path/2`
(locale-prefix swap on `:path`). Both are `defoverridable` if you need a
different scheme.
## use options
* `:base_url` — accepts a string (`"https://example.com"`) or a 0-arity
function capture (`&MyAppWeb.Endpoint.url/0`). When omitted, the macro
guesses the Endpoint module from your PageMeta's namespace.
## 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
%MyAppWeb.PageMeta{title: "Hello", path: "/hello"}
end
def mount(_params, _session, socket) do
{:ok, assign_page_meta(socket)}
end
end
See `PhoenixPageMeta.Breadcrumb`, `PhoenixPageMeta.Components.Breadcrumbs`,
`PhoenixPageMeta.Components.MetaTags`, `PhoenixPageMeta.Site`, and
`PhoenixPageMeta.LiveView`.
"""
@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.
"""
defmacro __using__(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
defp strip_query(path) do
case :binary.split(path, "?") do
[path, _] -> path
[path] -> path
end
end
end