guides/http-locale-discovery.md

# HTTP Locale Discovery

This guide covers detecting the user's locale from HTTP requests, persisting it in the session, and restoring it in LiveView using the plugs provided by `localize_web`.

## Prerequisites

Add `localize_web` and a Gettext backend to your project:

```elixir
# mix.exs
def deps do
  [
    {:localize_web, "~> 0.1.0"},
    {:gettext, "~> 1.0"}
  ]
end
```

Define a Gettext backend if you don't already have one:

```elixir
# lib/my_app/gettext.ex
defmodule MyApp.Gettext do
  use Gettext.Backend, otp_app: :my_app
end
```

As an alternative to standard Gettext interpolation, you can configure the backend to use [ICU MessageFormat 2](https://hexdocs.pm/localize/Localize.Gettext.Interpolation.html) for richer message formatting including plural rules, select expressions, and number/date formatting:

```elixir
defmodule MyApp.Gettext do
  use Gettext.Backend,
    otp_app: :my_app,
    interpolation: Localize.Gettext.Interpolation
end
```

## Standalone Accept-Language Parsing

The simplest way to detect a user's preferred locale is from the `Accept-Language` HTTP header sent by the browser. If that is all you need, use `Localize.Plug.AcceptLanguage`:

```elixir
# lib/my_app_web/router.ex
pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug Localize.Plug.AcceptLanguage
end
```

The detected locale is stored in `conn.private[:localize_locale]` and can be retrieved with:

```elixir
iex> locale = Localize.Plug.AcceptLanguage.get_locale(conn)
```

By default, a warning is logged when no configured locale matches the header. This can be changed or disabled:

```elixir
plug Localize.Plug.AcceptLanguage, no_match_log_level: :debug
plug Localize.Plug.AcceptLanguage, no_match_log_level: nil
```

## Full Locale Discovery with PutLocale

For most applications, use `Localize.Plug.PutLocale` which checks multiple sources in priority order and sets the locale from the first match:

```elixir
# lib/my_app_web/router.ex
pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug Localize.Plug.PutLocale,
    from: [:session, :accept_language, :query, :path],
    param: "locale",
    gettext: MyApp.Gettext
  plug Localize.Plug.PutSession
end
```

The Localize process locale is always set via `Localize.put_locale/1`. When a `:gettext` backend (or list of backends) is provided, the locale is also set on each Gettext backend.

### How Source Priority Works

The `:from` option controls the order of locale source lookup. In this example:

1. The session is checked first (preserving the user's previous choice).
2. The accept-language header is checked next.
3. Then query parameters (e.g. `?locale=fr`).
4. Finally, path parameters.

The first source that yields a valid locale wins. Remaining sources are not consulted.

### The :param Option

The `:param` option specifies the parameter name to look for in query, path, body, and cookie sources. The default is `"locale"`. For example, with `param: "lang"`, the plug will look for `?lang=fr` in query params or a `lang` path parameter.

### Configuring Gettext

The `:gettext` option accepts a single Gettext backend module or a list of backends:

```elixir
# Single backend
plug Localize.Plug.PutLocale, gettext: MyApp.Gettext

# Multiple backends
plug Localize.Plug.PutLocale, gettext: [MyApp.Gettext, MyOtherApp.Gettext]
```

When omitted, only the Localize process locale is set and no Gettext locale is configured.

The Gettext backend can use either the standard Gettext interpolation or the [Localize.Gettext.Interpolation](https://hexdocs.pm/localize/Localize.Gettext.Interpolation.html) module which supports ICU MessageFormat 2 (MF2). MF2 provides locale-aware plural rules, select expressions, and number/date formatting directly in your translation strings. See the [Localize.Message documentation](https://hexdocs.pm/localize/Localize.Message.html) for the full MF2 syntax reference.

### The Default Locale

When no source provides a locale, the `:default` option determines the fallback:

```elixir
# Use a specific locale (default is Localize.default_locale/0)
plug Localize.Plug.PutLocale, default: "en"

# Disable the fallback entirely
plug Localize.Plug.PutLocale, default: :none

# Use a custom function
plug Localize.Plug.PutLocale, default: {MyApp.Locale, :resolve_default}
```

## All Locale Sources

Beyond the common sources shown above, `Localize.Plug.PutLocale` supports these additional sources in the `:from` list:

* `:body` — looks for the locale parameter in `conn.body_params`.

* `:cookie` — looks for the locale parameter in the request cookies.

* `:host` — extracts the top-level domain from the hostname and resolves it to a locale. For example, `example.co.uk` resolves to a UK English locale. Generic TLDs like `.com`, `.org`, and `.net` are ignored.

* `:route` — reads the locale assigned to a route by the `localize/1` macro. See the [Phoenix Localized Routing](phoenix-localized-routing.md) guide for details.

* `{Module, function}` — calls `Module.function(conn, options)` and expects `{:ok, locale}` on success.

* `{Module, function, args}` — calls `Module.function(conn, options, ...args)` and expects `{:ok, locale}` on success.

### Custom Locale Resolution Example

```elixir
defmodule MyApp.LocaleResolver do
  def from_user(conn, _options) do
    case conn.assigns[:current_user] do
      %{preferred_locale: locale} when is_binary(locale) ->
        Localize.validate_locale(locale)
      _ ->
        {:error, :no_user}
    end
  end
end

# In the router
plug Localize.Plug.PutLocale,
  from: [{MyApp.LocaleResolver, :from_user}, :session, :accept_language],
  gettext: MyApp.Gettext
```

## Persisting Locale in the Session

`Localize.Plug.PutSession` saves the discovered locale to the session so it persists across requests. It should always be placed after `Localize.Plug.PutLocale` in the plug pipeline:

```elixir
plug Localize.Plug.PutLocale, ...
plug Localize.Plug.PutSession, as: :string
```

The `:as` option controls the storage format:

* `:string` (default) — converts the locale to a string before storing. This minimizes session size at the expense of CPU time to serialize and parse on subsequent requests.

* `:language_tag` — stores the full `%Localize.LanguageTag{}` struct. This minimizes CPU time at the expense of larger session storage.

The session key is fixed to `"localize_locale"` so that downstream consumers (such as LiveView `on_mount` callbacks) can retrieve the locale without configuration.

## LiveView Integration

In LiveView, the HTTP plug pipeline runs only on the initial page load. For subsequent live navigation, the locale must be restored from the session in the `on_mount` callback:

```elixir
defmodule MyAppWeb.LocaleLive do
  def on_mount(:default, _params, session, socket) do
    {:ok, _locale} = Localize.Plug.put_locale_from_session(
      session,
      gettext: MyApp.Gettext
    )
    {:cont, socket}
  end
end
```

Then attach it in your router:

```elixir
live_session :default, on_mount: [MyAppWeb.LocaleLive] do
  scope "/", MyAppWeb do
    live "/dashboard", DashboardLive
  end
end
```

The `put_locale_from_session/2` function reads the locale from the session (stored by `Localize.Plug.PutSession`) and sets it for both Localize and Gettext in the LiveView process.

## Accessing the Locale in Controllers and Views

After the plug pipeline runs, the locale is available in several ways:

```elixir
# From the conn (set by PutLocale)
locale = Localize.Plug.PutLocale.get_locale(conn)

# From the Localize process dictionary
locale = Localize.get_locale()
```

## Putting It All Together

A typical Phoenix application plug pipeline for full localization support:

```elixir
pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :fetch_live_flash
  plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
  plug :protect_from_forgery
  plug :put_secure_browser_headers
  plug Localize.Plug.PutLocale,
    from: [:route, :session, :accept_language],
    gettext: MyApp.Gettext
  plug Localize.Plug.PutSession
end
```

Note that `:route` is listed first in `:from`. This means that when a user visits a localized route (e.g. `/fr/utilisateurs`), the locale embedded in that route takes priority. The session serves as a fallback for non-localized routes, and the accept-language header provides a sensible default for first-time visitors.