# Per-Module i18n with Gettext

**Translate sidebar tab labels, group labels, and tooltips inside your PhoenixKit module.**

This guide shows how each PhoenixKit module owns its own Gettext backend, ships its own `.po` files, and registers admin/settings/dashboard tabs with `gettext_backend:` so labels translate at render time according to the user's locale. Applies to **every** module that exposes UI — both new modules being authored from day 1 and existing modules being uplifted to the PhoenixKit release that introduces the `gettext_backend` / `gettext_domain` API. Confirm the exact minimum version against the PhoenixKit CHANGELOG.

---

## Quick Start

```elixir
# 1. Create the module's Gettext backend
defmodule PhoenixKitProjects.Gettext do
  use Gettext.Backend, otp_app: :phoenix_kit_projects
end

# 2. In mix.exs
def application, do: [extra_applications: [:logger, :gettext]]

defp deps do
  [
    # Use the version that introduces the gettext_backend API
    # — check PhoenixKit's CHANGELOG for the exact minimum.
    {:phoenix_kit, "~> X.Y"},
    {:gettext, "~> 1.0"}
  ]
end

# 3. Register tabs with gettext_backend
@impl PhoenixKit.Module
def admin_tabs do
  [
    Tab.new!(
      id: :admin_projects,
      label: "Projects",
      icon: "hero-folder",
      path: "projects",
      priority: 400,
      level: :admin,
      permission: "projects",
      group: :admin_modules,
      gettext_backend: PhoenixKitProjects.Gettext
    )
  ]
end

# 4. Extract msgids and fill translations
# $ mix gettext.extract --merge
# Edit priv/gettext/ru/LC_MESSAGES/default.po
#   msgid "Projects"
#   msgstr "Проекты"
```

---

## What core gives you

Starting with the PhoenixKit release that introduces this API (see CHANGELOG for the exact version), `PhoenixKit.Dashboard.Tab` and `PhoenixKit.Dashboard.Group` accept two optional fields:

| Field | Type | Default | Purpose |
|-------|------|---------|---------|
| `gettext_backend` | `module()` or `nil` | `nil` | Module that owns the Gettext catalogue for this tab/group. `nil` keeps the raw label. |
| `gettext_domain` | `String.t()` | `"default"` | Gettext domain to look the msgid up in. |

Sidebar / `AdminSidebar` / `TabItem` components automatically route every label and tooltip render through:

- `Tab.localized_label/1`
- `Tab.localized_tooltip/1`
- `Group.localized_label/1`

Each helper:

1. Returns `nil` if the underlying field (`label` / `tooltip`) is `nil` — divider tabs and unlabeled groups stay safe.
2. Returns the raw string if `gettext_backend` is `nil` — backwards compatible.
3. Otherwise calls `Gettext.dgettext(backend, domain, msgid)` against the **process locale** of the LiveView. Locale is set per request by the parent app's locale plug or on-mount hook. **Modules must not set the locale themselves.**

---

## Why each module owns its own backend

When a module ships as a separate Hex package, it cannot rely on the parent application's `PhoenixKitWeb.Gettext` — that backend belongs to the core library, not to your package. Each module ships its own `.po` files in its own `priv/gettext/` and registers translations with `gettext_backend: PhoenixKit<X>.Gettext`. The parent app sets the user's locale once per request; every module's backend then independently looks up its own msgids in its own catalogue.

This also matches the `dynamic_children/2` callback contract: arity-2 dynamic-children functions already receive the current locale, but using `gettext_backend:` on returned `%Tab{}` structs is more declarative and avoids manual `Gettext.put_locale/2` juggling.

---

## Setup checklist

| # | Step | Where |
|---|------|-------|
| 1 | Bump the `phoenix_kit` dep to the release that ships the `gettext_backend` API (see PhoenixKit CHANGELOG) | `mix.exs` |
| 2 | Add `:gettext` to `extra_applications` and to `deps` | `mix.exs` |
| 3 | Add `priv` to `package: [files: ...]` so `.po` files ship to Hex | `mix.exs` |
| 4 | Create the module's own Gettext backend | `lib/phoenix_kit_<x>/gettext.ex` |
| 5 | Replace every `use Gettext, backend: PhoenixKitWeb.Gettext` with the module's own backend | `grep -rl "PhoenixKitWeb.Gettext" lib/` |
| 6 | Set `gettext_backend:` (and `gettext_domain:` if needed) on **every** `%Tab{}` and `%Group{}` registration | `admin_tabs/0`, route module |
| 7 | Maintain `priv/gettext/default.pot` and `priv/gettext/<locale>/LC_MESSAGES/default.po` manually (`mix gettext.extract` does **not** see `Tab.new!(label: "…")` plain strings) | `priv/gettext/` |
| 8 | Fill translations for each target locale (`en` is 1:1) | `priv/gettext/<locale>/LC_MESSAGES/default.po` |
| 9 | Add conditional skip in `test/test_helper.exs` so CI building against a pre-API `phoenix_kit` doesn't fail (see [§ Conditional CI skip](#conditional-ci-skip)) | `test/test_helper.exs` |
| 10 | Add a smoke test (see [§ Test pattern](#test-pattern)) | `test/` |

> **Do NOT bump `@version` or write a `CHANGELOG.md` entry.** Both are maintainer-owned across **core and every `phoenix_kit_<x>` child module** — the maintainer derives the version and the CHANGELOG entry from your commit messages at release time. Write descriptive commit messages; that's the contribution. See [§ Version and CHANGELOG ownership](#version-and-changelog-ownership).

---

## Step-by-step setup

### A. Create the Gettext backend

```elixir
# lib/phoenix_kit_<x>/gettext.ex
defmodule PhoenixKit<X>.Gettext do
  @moduledoc """
  Gettext backend for phoenix_kit_<x>.

  Locale is set per-request by the parent application; this module's only
  responsibility is owning the catalogues under `priv/gettext/`.
  """
  use Gettext.Backend, otp_app: :phoenix_kit_<x>
end
```

`use Gettext.Backend, otp_app: ...` is the **`Gettext 0.26+` form**. The older `use Gettext, otp_app: ...` style is deprecated; do not use it.

### B. `mix.exs`

```elixir
def application do
  [
    extra_applications: [:logger, :gettext]   # :gettext is required at runtime
  ]
end

defp deps do
  [
    # Use the version that introduces the gettext_backend API
    # — check PhoenixKit's CHANGELOG for the exact minimum.
    {:phoenix_kit, "~> X.Y"},
    {:gettext, "~> 1.0"}
  ]
end
```

### C. Switch existing `use Gettext` calls

Find every file in your module that currently uses the parent app's backend:

```bash
grep -rl "PhoenixKitWeb.Gettext" lib/
```

Replace:

```elixir
# Before
use Gettext, backend: PhoenixKitWeb.Gettext

# After
use Gettext, backend: PhoenixKit<X>.Gettext
```

This is mandatory before `hex.publish` — your published package must not reference the parent app's backend.

### D. Wire your tabs and groups

Every `%Tab{}` and `%Group{}` registered by your module's `admin_tabs/0`, `settings_tabs/0`, `user_dashboard_tabs/0`, or `dynamic_children/2` callback must carry the backend:

```elixir
@impl PhoenixKit.Module
def admin_tabs do
  [
    Tab.new!(
      id: :admin_projects,
      label: "Projects",
      icon: "hero-folder",
      path: "projects",
      priority: 400,
      level: :admin,
      permission: "projects",
      group: :admin_modules,
      gettext_backend: PhoenixKit<X>.Gettext,
      gettext_domain: "default"     # optional — "default" is the default
    ),
    Tab.new!(
      id: :admin_projects_new,
      label: "New Project",
      path: "projects/new",
      priority: 410,
      level: :admin,
      permission: "projects",
      parent: :admin_projects,
      gettext_backend: PhoenixKit<X>.Gettext
    )
  ]
end
```

Groups, when your module contributes them:

```elixir
%Group{
  id: :projects_section,
  label: "Project management",
  priority: 400,
  collapsible: true,
  gettext_backend: PhoenixKit<X>.Gettext
}
```

### E. `dynamic_children/2` — locale-aware children

If your tab uses `dynamic_children:` to render child tabs at runtime (e.g. one tab per project), implement the **arity-2** form. Children only need the backend set on items whose labels are msgids; user-supplied data stays raw:

```elixir
%Tab{
  id: :admin_projects,
  label: "Projects",
  # ... other fields ...
  dynamic_children: fn _scope, _locale ->
    PhoenixKit.RepoHelper.repo().all(Project)
    |> Enum.map(fn project ->
      %Tab{
        id: :"admin_project_#{project.id}",
        label: project.name,        # raw user-supplied — no translation needed
        path: "projects/#{project.id}",
        parent: :admin_projects,
        level: :admin,
        permission: "projects"
        # gettext_backend NOT set — project names are user data, not msgids
      }
    end)
  end
}
```

> **Rule of thumb:** set `gettext_backend:` only when `label`/`tooltip` is a fixed English msgid that lives in your `.po` files. User-supplied content (project names, document titles, customer names) stays raw.

### F. Dividers and group headers

`Tab.divider/1` and `Tab.group_header/1` accept the same options:

```elixir
Tab.divider(
  priority: 150,
  label: "Account",
  gettext_backend: PhoenixKit<X>.Gettext
)

Tab.group_header(
  id: :reports_header,
  label: "Reports",
  priority: 500,
  gettext_backend: PhoenixKit<X>.Gettext
)
```

### G. Tooltips

Tooltips translate via the same backend automatically — set `tooltip:` to the msgid alongside `gettext_backend:` and the sidebar's `title=` attribute will render the translated text:

```elixir
Tab.new!(
  id: :admin_projects,
  label: "Projects",
  tooltip: "Manage all projects",   # msgid for tooltip translation
  path: "projects",
  gettext_backend: PhoenixKit<X>.Gettext
)
```

### H. Extract msgids and fill translations

```bash
# Generate / update the .pot (template)
mix gettext.extract

# Merge new msgids into each locale's .po
mix gettext.merge priv/gettext --locale en
mix gettext.merge priv/gettext --locale ru
mix gettext.merge priv/gettext --locale et
```

Edit `priv/gettext/<locale>/LC_MESSAGES/default.po`:

```po
#: lib/phoenix_kit_<x>/<x>.ex
msgid "Projects"
msgstr "Проекты"

#: lib/phoenix_kit_<x>/<x>.ex
msgid "New Project"
msgstr "Новый проект"

#: lib/phoenix_kit_<x>/<x>.ex
msgid "Manage all projects"
msgstr "Управление всеми проектами"
```

For `en`: `msgstr` should equal `msgid` (gettext's "no translation needed" convention; without it, gettext returns the empty string for `en` and your label disappears).

---

## Greenfield module example

Full minimal module with i18n built in from day 1:

```elixir
# lib/phoenix_kit_<x>/<x>.ex
defmodule PhoenixKit<X> do
  use PhoenixKit.Module

  alias PhoenixKit.Dashboard.{Tab, Group}

  @impl PhoenixKit.Module
  def module_key, do: "<x>"

  @impl PhoenixKit.Module
  def module_name, do: "<X> Module"

  @impl PhoenixKit.Module
  def permission_metadata do
    %{
      key: "<x>",
      label: "<X>",
      icon: "hero-folder",
      description: "<short description>"
    }
  end

  @impl PhoenixKit.Module
  def admin_tabs do
    [
      Tab.new!(
        id: :admin_<x>,
        label: "<X>",
        icon: "hero-folder",
        path: "<x>",
        priority: 400,
        level: :admin,
        permission: "<x>",
        group: :admin_modules,
        gettext_backend: PhoenixKit<X>.Gettext
      )
    ]
  end
end
```

```elixir
# lib/phoenix_kit_<x>/gettext.ex
defmodule PhoenixKit<X>.Gettext do
  @moduledoc "Gettext backend for phoenix_kit_<x>."
  use Gettext.Backend, otp_app: :phoenix_kit_<x>
end
```

---

## Retrofitting an existing module

For each existing `phoenix_kit_<x>` module being uplifted to the new API:

- [ ] `mix.exs` — confirm `phoenix_kit` dep constraint admits the release that ships `gettext_backend` (typically `~> 1.7` is wide enough; pin tighter if needed)
- [ ] `mix.exs` — `:gettext` is in `extra_applications` AND `{:gettext, "~> 1.0"}` is in `deps`
- [ ] `mix.exs` — `package: [files: ~w(lib priv …)]` includes `priv` (verify with `mix hex.build` + `tar -tzf | grep priv/gettext`)
- [ ] Create `lib/phoenix_kit_<x>/gettext.ex` with `use Gettext.Backend, otp_app: :phoenix_kit_<x>`
- [ ] `grep -rl "PhoenixKitWeb.Gettext" lib/` returns **zero** results
- [ ] Maintain `priv/gettext/default.pot` and `priv/gettext/{en,ru,et,…}/LC_MESSAGES/default.po` manually — `mix gettext.extract` does **not** see plain `Tab.new!(label: "…")` strings (no `dgettext` macro call). `en/default.po` has `msgstr` = `msgid` for every entry; `ru`/`et` are filled
- [ ] Every `Tab.new!`, `%Tab{}`, `Tab.divider/1`, `Tab.group_header/1`, `%Group{}`, `Group.new/1` in your module sets `gettext_backend:`
- [ ] `dynamic_children:` callbacks return tabs with `gettext_backend:` set (when labels are msgids, not user data)
- [ ] `test/test_helper.exs` has the conditional `:requires_phoenix_kit_i18n_api` skip (see [§ Conditional CI skip](#conditional-ci-skip))
- [ ] Smoke test in `test/phoenix_kit/<x>/i18n_test.exs` carries `@moduletag :requires_phoenix_kit_i18n_api` and passes locally with `phoenix_kit` resolved to a release that ships the API
- [ ] `mix test` clean (locally with API; on CI without API, i18n tests excluded automatically — also clean)
- [ ] **Do NOT** bump `@version` or write a CHANGELOG entry — both are maintainer-owned (see [§ Version and CHANGELOG ownership](#version-and-changelog-ownership)). Write a descriptive commit message instead.
- [ ] Commit on a feature branch, push, open PR.

---

## Hex package shape

`mix.exs` `package files:` **must include `priv`**. The directory is otherwise excluded from the Hex tarball, so the new `.po` files would not ship and `Gettext.dgettext/3` would silently return raw msgids in production for every consumer that installed from Hex.

```elixir
defp package do
  [
    licenses: ["MIT"],
    links: %{"GitHub" => @source_url},
    files: ~w(lib priv .formatter.exs mix.exs README.md CHANGELOG.md LICENSE)
    #         ^^^^ this
  ]
end
```

Verify locally with:

```bash
mix hex.build
tar -tzf phoenix_kit_<x>-*.tar | grep priv/gettext
# should list every .po and .pot file
```

---

## Conditional CI skip

The `gettext_backend` API was introduced by [PR #522](https://github.com/BeamLabEU/phoenix_kit/pull/522) on `phoenix_kit` core. Until the consumer's `phoenix_kit` dep resolves to a published release that includes it, `PhoenixKit.Dashboard.Tab.localized_label/1` does not exist and the i18n smoke test would raise `UndefinedFunctionError`. To keep CI green on consumers who haven't yet upgraded, gate the smoke test with a `:requires_phoenix_kit_i18n_api` tag and detect availability in `test_helper.exs`:

```elixir
# test/test_helper.exs
require Logger

if Code.ensure_loaded?(PhoenixKit.Dashboard.Tab) and
     function_exported?(PhoenixKit.Dashboard.Tab, :localized_label, 1) do
  ExUnit.start()
else
  Logger.info(
    "[test_helper] PhoenixKit.Dashboard.Tab.localized_label/1 not available — " <>
      "i18n tests excluded. They will run automatically once `phoenix_kit` is " <>
      "upgraded to a release that ships the gettext_backend API."
  )

  ExUnit.start(exclude: [:requires_phoenix_kit_i18n_api])
end
```

```elixir
# test/phoenix_kit/<x>/i18n_test.exs
defmodule PhoenixKit.<X>.I18nTest do
  use ExUnit.Case, async: false

  @moduletag :requires_phoenix_kit_i18n_api

  # ...
end
```

`Code.ensure_loaded?/1` is load-bearing — without it, `function_exported?/3` returns `false` if the `Tab` module hasn't been loaded yet at helper-init time, and the i18n tests get excluded even when the API is available.

---

## Version and CHANGELOG ownership

**The package version (`@version` in `mix.exs`) and the `CHANGELOG.md` are owned by the maintainer.** This applies uniformly to `phoenix_kit` core **and** every `phoenix_kit_<x>` child module — even the modules whose forks you maintain locally. The maintainer derives the version bump (patch / minor / major) and the CHANGELOG entry from the commit messages on the merged PR; agents and contributors do not touch either.

What contributors **do**:

- Write descriptive commit messages that read like a CHANGELOG entry — what changed, why, links to related issues / PRs / dependencies. The first line is the title; the body explains the rest.
- Leave `@version` exactly as it stands at HEAD.
- Leave `CHANGELOG.md` exactly as it stands at HEAD.

What contributors **do NOT**:

- Edit `@version` in `mix.exs` (even a single patch bump).
- Add or modify entries in `CHANGELOG.md`.

If you find yourself wanting to bump the version "for visibility", stop and improve the commit message instead. The maintainer reads commit bodies when assembling the release notes.

This rule is intentional: it keeps version bumps centralized so the maintainer can squash a feature PR and a follow-up fix into a single release without coordinating two version-line edits, and it removes a frequent source of merge conflicts on `mix.exs` `@version` and `CHANGELOG.md` first-entry header.

---

## Test pattern

A single smoke test per module is sufficient — core's tests already cover the localization machinery itself:

```elixir
defmodule PhoenixKit<X>.I18nSmokeTest do
  use ExUnit.Case, async: false

  # Excluded by `test/test_helper.exs` when running against a `phoenix_kit`
  # release that pre-dates the `gettext_backend` API. Once the consumer
  # upgrades, the helper detects it and these tests run automatically.
  @moduletag :requires_phoenix_kit_i18n_api

  alias PhoenixKit.Dashboard.Tab

  setup do
    original = Gettext.get_locale(PhoenixKit<X>.Gettext)
    on_exit(fn -> Gettext.put_locale(PhoenixKit<X>.Gettext, original) end)
    :ok
  end

  test "admin tab labels translate to ru" do
    Gettext.put_locale(PhoenixKit<X>.Gettext, "ru")

    [tab | _] = PhoenixKit<X>.admin_tabs()

    # Replace with the actual translation you put in ru/default.po
    assert Tab.localized_label(tab) == "Проекты"
  end

  test "admin tab labels fall back to msgid for an unknown locale" do
    Gettext.put_locale(PhoenixKit<X>.Gettext, "xx")

    [tab | _] = PhoenixKit<X>.admin_tabs()
    assert Tab.localized_label(tab) == tab.label
  end
end
```

`async: false` is required because `Gettext.put_locale/2` mutates the calling process's process dictionary; `on_exit` restores it cleanly. Pair this module with the conditional helper from [§ Conditional CI skip](#conditional-ci-skip).

---

## Common pitfalls

❌ **Do NOT** call `Gettext.put_locale/2` from inside your module — locale is a request-scoped concern owned by the parent app.

❌ **Do NOT** translate before passing to PhoenixKit core APIs that persist data. For example, `Tab.label` is the source of the row label that `Permissions.register_custom_key/2` writes to the database. Translating it before registration would corrupt the canonical key store. Pass the raw msgid; rendering localizes.

❌ **Do NOT** invent a custom domain unless you actually have multiple. `"default"` is the convention; switch to a domain-per-area only when one `default.po` becomes too noisy.

❌ **Do NOT** keep `use Gettext, backend: PhoenixKitWeb.Gettext` in published code. That backend belongs to PhoenixKit core and won't be mounted in every consumer app the same way.

❌ **Do NOT** set `gettext_backend:` on dynamically-generated user-data labels (project names, document titles). These are not msgids; gettext would return the raw string anyway, but the field still wastes a Gettext call per render.

❌ **Do NOT** ship without `mix gettext.extract --merge` — stale `.pot` means newly added msgids never reach `.po` and translators have nothing to translate.

❌ **Do NOT** pattern-match on `gettext_backend` / `gettext_domain` from a generic Tab handler. The library's resolvers (`Tab.localized_label/1` etc.) deliberately use `Map.get/2` so old-shape Tab structs cached in ETS or `:persistent_term` from before the upgrade — missing both new keys — flow through gracefully instead of raising `KeyError`. If you write your own iteration over `PhoenixKit.ModuleRegistry.all_admin_tabs/0`, prefer `Tab.localized_label/1` over reaching into the struct directly. This matters during the rolling-upgrade window where a parent app's Phoenix server keeps running across the `phoenix_kit` upgrade.

---

## Where this fits in the rollout

| Phase | Repo | What |
|-------|------|------|
| 1 ✅ | `phoenix_kit` (core) | `gettext_backend` / `gettext_domain` API merged on `dev`. Maintainer ships the API in an upcoming release — see PhoenixKit CHANGELOG for the version. |
| 2 ⏳ | each `phoenix_kit_<x>` package | Apply this guide. Pilot: `phoenix_kit_projects` |
| 3 ⏳ | parent apps | Drop ETS-patching hacks; pass `gettext_backend:` to their own tabs |

Phase 2 modules are independent of one another — they can be migrated in parallel by different developers, one PR per repo. Phase 3 happens at any point after phase 1 ships, regardless of phase 2 progress.

---

## Reference

- [`PhoenixKit.Dashboard.Tab`](https://phoenix-kit.hexdocs.pm/PhoenixKit.Dashboard.Tab.html) — `localized_label/1`, `localized_tooltip/1`, `divider/1`, `group_header/1`
- [`PhoenixKit.Dashboard.Group`](https://phoenix-kit.hexdocs.pm/PhoenixKit.Dashboard.Group.html) — `localized_label/1`
- [Gettext docs](https://hexdocs.pm/gettext/) — `dgettext`, `Gettext.Backend`, `mix gettext.extract`, `mix gettext.merge`
