Skip to main content

README.md

# PhoenixPageMeta

[![Hex.pm](https://img.shields.io/hexpm/v/phoenix_page_meta.svg)](https://hex.pm/packages/phoenix_page_meta)
[![Hexdocs](https://img.shields.io/badge/hex-docs-purple.svg)](https://hexdocs.pm/phoenix_page_meta)
[![CI](https://github.com/saschabrink/phoenix_page_meta/actions/workflows/ci.yml/badge.svg)](https://github.com/saschabrink/phoenix_page_meta/actions/workflows/ci.yml)
[![License](https://img.shields.io/hexpm/l/phoenix_page_meta.svg)](https://github.com/saschabrink/phoenix_page_meta/blob/main/LICENSE)

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
* a layout's worth of SEO meta tags hand-built per project. 

PhoenixPageMeta standardises all three: each LiveView declares a `%PageMeta{}` struct for the current page, and the library renders the SEO meta tags, builds the breadcrumb trail, and resolves active-link state from it. Site-wide values and any custom fields are set in `config :phoenix_page_meta`.

## Installation

```elixir
def deps do
  [{:phoenix_page_meta, "~> 0.2"}]
end
```

With [Igniter](https://hex.pm/packages/igniter), the installer adds the config, the root.html.heex meta tags, and the LiveView wiring automatically:

```sh
mix igniter.install phoenix_page_meta
```

## Setup

Each page is described by a `%PhoenixPageMeta.PageMeta{}`. The base URL is
auto-detected from your Phoenix endpoint, so it works with no configuration:

```elixir
%PhoenixPageMeta.PageMeta{title: "Hello", path: "/hello"}
```

### Configuration

All config is optional — add it to set site-wide defaults or extend the struct:

```elixir
# config/config.exs
config :phoenix_page_meta,
  site_name: "MyApp",
  twitter_site: "@myapp",
  supported_locales: [:en, :es],
  extra_fields: [icon: nil, modal: false]
```

- Site-wide values (`site_name`, `twitter_site`, `supported_locales`,
  `og_type`) become struct defaults; a page may override any of them.
- `extra_fields` adds custom fields (bare atoms and `{key, default}` pairs may
  be mixed). Make some required with `extra_enforce_keys: [...]`.
- `base_url` is auto-detected from the single running `Phoenix.Endpoint`. Set
  `base_url:` (string, `&Mod.url/0`, or `{m, f, a}`) or `endpoint:
  MyAppWeb.Endpoint` only if detection is ambiguous (e.g. multiple endpoints).

> Field-set/default config is read with `Application.compile_env/3`. After
> changing `:extra_fields`, `:extra_enforce_keys`, or the site-wide defaults, run
> `mix deps.compile phoenix_page_meta --force`.

### 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
```

Add `alias PhoenixPageMeta.PageMeta` inside your `html_helpers` so templates and
LiveViews can write `%PageMeta{}` unprefixed (the examples below assume this).

## 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
  # assumes `alias PhoenixPageMeta.PageMeta` in your MyAppWeb html_helpers

  @impl PhoenixPageMeta.LiveView
  def page_meta(socket, :show) do
    location = socket.assigns.location
    %PageMeta{
      title: location.name,
      path: ~p"/locations/#{location.slug}",
      description: location.summary,
      parent: %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 `PageMeta.active?/2`:

```heex
<.link navigate={~p"/locations"} class={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>
```

(`breadcrumb.page_meta.icon` assumes you added `icon` via `extra_fields`.)

## 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 |
| `: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()` | default from `config :phoenix_page_meta, :og_type` (else `"website"`) |
| `:json_ld` | `map() \| nil` | rendered as `<script type="application/ld+json">` |
| `:canonical_path` | `String.t() \| nil` | overrides `:path` for canonical URL |
| `:noindex` | `boolean()` | default `false` |
| `:locale` | `atom() \| nil` | current page's locale; renders `og:locale` |
| `:supported_locales` | `[atom()] \| nil` | hreflang tags + `og:locale:alternate`; default from config |
| `:site_name` | `String.t() \| nil` | renders `og:site_name`; default from config |
| `:twitter_site` | `String.t() \| nil` | renders `twitter:site` (e.g. `"@handle"`); default from config |
| `:skip_breadcrumb` | `boolean() \| nil` | when `true`, this page is filtered out of the breadcrumb (modals, overlays) |

Add app-specific fields via `config :phoenix_page_meta, extra_fields: [...]` (e.g. `:icon`, `:modal`).

## Migrating from `use PhoenixPageMeta`

Before 0.2.0 you declared a per-app `MyAppWeb.PageMeta` module with `use
PhoenixPageMeta` and an explicit `defstruct`. That path is **deprecated** (it
still works but emits a compile-time warning) and will be removed in a future
release. To migrate:

1. Move site-wide struct defaults into `config :phoenix_page_meta`, and any
   custom fields into `extra_fields`.
2. Replace `%MyAppWeb.PageMeta{}` with `%PhoenixPageMeta.PageMeta{}` (or just
   re-point your `alias`).
3. Delete the `MyAppWeb.PageMeta` module.

The only capability the `use` macro offers that the prebuilt struct does not is
multiple distinct PageMeta modules (e.g. an umbrella with several endpoints).

## License

MIT