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