# PhoenixPageMeta
Per-page metadata for Phoenix LiveView apps: breadcrumbs, active-link state, SEO meta tag rendering. One struct per page, one render in the layout, no per-project re-implementation of the same logic in three different places.
## Why
Every Phoenix app I write ends up with the same trio: breadcrumb logic somewhere, active-link helpers duplicated across sidebar/nav/layout components, and a layout's worth of meta tags hand-built per project. PhoenixPageMeta standardises all three around a single struct that each LiveView declares once.
The struct lives explicitly in your project — full visibility, no macro that hides what fields you have. The macro injects only the wiring around it (behaviour, helpers, validation).
## Installation
```elixir
def deps do
[{:phoenix_page_meta, "~> 0.1"}]
end
```
## Setup
One file. No `config.exs` entry.
```elixir
defmodule MyAppWeb.PageMeta do
use PhoenixPageMeta
@enforce_keys [:title, :path]
defstruct [
:title,
:path,
:breadcrumb_title,
:parent,
:description,
:og_image,
:og_image_alt,
:json_ld,
:canonical_path,
:icon,
:skip_breadcrumb,
:locale,
og_type: "website",
noindex: false,
supported_locales: [:en, :es],
site_name: "MyApp",
twitter_site: "@myapp"
]
end
```
`use PhoenixPageMeta` injects:
- `@behaviour PhoenixPageMeta.Site`
- `breadcrumbs/1` and `active?/2,3` wrappers that pattern-match `MyAppWeb.PageMeta`
- Default `base_url/0` (auto-detects `MyAppWeb.Endpoint.url()` from the namespace)
- Default `lang_path/2` (locale-prefix swap)
- `@after_compile` validation that `:title`, `:path`, `:parent` exist on the struct
Both defaults are `defoverridable` if your project deviates.
### `use` options
```elixir
use PhoenixPageMeta, base_url: "https://example.com" # explicit string
use PhoenixPageMeta, base_url: &MyAppWeb.Endpoint.url/0 # explicit function capture
use PhoenixPageMeta # auto-guess MyAppWeb.Endpoint
```
### Wire LiveView
In `MyAppWeb.live_view/0`:
```elixir
def live_view do
quote do
use Phoenix.LiveView, layout: {MyAppWeb.Layouts, :app}
@behaviour PhoenixPageMeta.LiveView
import PhoenixPageMeta.LiveView, only: [assign_page_meta: 1]
end
end
```
## Usage
In each LiveView, implement `page_meta/2` and call `assign_page_meta/1` after data is loaded:
```elixir
defmodule MyAppWeb.LocationLive.Show do
use MyAppWeb, :live_view
@impl PhoenixPageMeta.LiveView
def page_meta(socket, :show) do
location = socket.assigns.location
%MyAppWeb.PageMeta{
title: location.name,
path: ~p"/locations/#{location.slug}",
description: location.summary,
parent: %MyAppWeb.PageMeta{title: "Locations", path: ~p"/locations"}
}
end
def handle_params(params, _uri, socket) do
{:noreply,
socket
|> assign(:location, load_location(params))
|> assign_page_meta()}
end
end
```
In your root layout, render the meta tags:
```heex
<head>
<PhoenixPageMeta.Components.MetaTags.default page_meta={@page_meta} />
<.live_title>{@page_title}</.live_title>
</head>
```
In nav components, use `MyAppWeb.PageMeta.active?/2`:
```heex
<.link navigate={~p"/locations"} class={MyAppWeb.PageMeta.active?(@page_meta, ~p"/locations") && "active"}>
Locations
</.link>
```
For breadcrumbs, use the slot-based component (handles `aria-label`, `aria-current`, divider placement):
```heex
<PhoenixPageMeta.Components.Breadcrumbs.list page_meta={@page_meta}>
<:link :let={breadcrumb}>
<.link navigate={breadcrumb.path} class="hover:underline truncate">
<.icon :if={breadcrumb.page_meta.icon} name={breadcrumb.page_meta.icon} class="size-4" />
{breadcrumb.title}
</.link>
</:link>
<:current :let={breadcrumb}>
<span class="font-medium truncate">{breadcrumb.title}</span>
</:current>
<:divider>
<span class="text-base-content/30">/</span>
</:divider>
</PhoenixPageMeta.Components.Breadcrumbs.list>
```
## Module structure
```
PhoenixPageMeta # __using__ macro, active?/2,3 (lib-level)
PhoenixPageMeta.Breadcrumb # struct + build/1
PhoenixPageMeta.Components.Breadcrumbs # list/1 component
PhoenixPageMeta.Components.MetaTags # default/1 component (SEO)
PhoenixPageMeta.Site # behaviour: base_url/0, lang_path/2
PhoenixPageMeta.LiveView # behaviour: page_meta/2 + assign_page_meta/1
```
The components dispatch site-wide callbacks (`base_url`, `lang_path`) via `page_meta.__struct__` — no global config needed.
## Standard fields
| Field | Type | Notes |
|---|---|---|
| `:title` | `String.t()` | required |
| `:path` | `String.t()` | required |
| `:breadcrumb_title` | `String.t() \| nil` | falls back to `:title` in breadcrumbs |
| `:parent` | `t() \| nil` | parent page; walked for breadcrumbs (required field for the `@after_compile` check) |
| `:description` | `String.t() \| nil` | meta description, og:description, twitter:description |
| `:og_image` | `String.t() \| nil` | OG and Twitter image. Relative paths are auto-prefixed with `base_url`; absolute URLs (`http://`/`https://`) are rendered as-is |
| `:og_image_alt` | `String.t() \| nil` | alt text for og:image; falls back to `:title` |
| `:og_type` | `String.t()` | suggested default `"website"` |
| `:json_ld` | `map() \| nil` | rendered as `<script type="application/ld+json">` |
| `:canonical_path` | `String.t() \| nil` | overrides `:path` for canonical URL |
| `:noindex` | `boolean()` | suggested default `false` |
| `:locale` | `atom() \| nil` | current page's locale; renders `og:locale` |
| `:supported_locales` | `[atom()] \| nil` | hreflang tags + `og:locale:alternate` |
| `:site_name` | `String.t() \| nil` | renders `og:site_name`. Typically a project-wide defstruct default |
| `:twitter_site` | `String.t() \| nil` | renders `twitter:site` (e.g. `"@handle"`). Typically a project-wide defstruct default |
| `:skip_breadcrumb` | `boolean() \| nil` | when `true`, this page is filtered out of the breadcrumb (modals, overlays) |
`MetaTags.default` reads optional fields with `Map.get/2`, so your struct only needs the fields you actually use. Add project-specific fields freely (e.g. `:icon`, `:twitter_handle`, `:modal`).
## License
MIT