Phoenix

Localhero.ai translates Phoenix gettext .po files on every pull request. New and changed messages are translated and committed back to the same PR, so you always have on-brand translations ready to deploy with each PR.

Prerequisites

  • A Phoenix or Elixir app using gettext, with catalogs in priv/gettext/
  • Node 18 or later, to run the CLI through npx
  • A Localhero.ai API key

Get an API key from your account.

Setup

1. Initialize the project

Run the CLI in your project root. No install step, and it detects an Elixir project from your mix.exs, proposing priv/gettext/ with a **/*.po pattern:

npx @localheroai/cli init

The CLI runs through npx, the one non-Elixir dependency in the setup. It is needed by the CLI, never by your app.

2. Check the generated config

init writes a localhero.json in your project root. For a standard Phoenix app it should come out looking like this:

localhero.json
{
  "schemaVersion": "1.0",
  "projectId": "your-project-id",
  "sourceLocale": "en",
  "outputLocales": ["sv", "nb", "de"],
  "translationFiles": {
    "paths": ["priv/gettext/"],
    "pattern": "**/*.po"
  }
}

Commit this file. Fields are documented in Project Setup.

3. Add the GitHub Action workflow

init offers to create the workflow for you, running localheroai/localhero-action. Add your API key as the repository secret LOCALHERO_API_KEY on GitHub.

One thing to add yourself: the generated file has no extract step. Put mix gettext.extract --merge before the Localhero.ai step, as below. Without it the job runs green and translates nothing, because only messages already in your .po files are visible to it.

Adjust the paths: filter if your catalogs live somewhere other than priv/gettext/:

.github/workflows/localhero-translate.yml
name: Localhero.ai - Automatic I18n translation

on:
  pull_request:
    paths:
      - "priv/gettext/**"
      - "lib/**"
      - "localhero.json"
  repository_dispatch:
    types: [localhero-sync]
  workflow_dispatch:

concurrency:
  group: translate-${{ github.event.client_payload.branch || github.head_ref || github.run_id }}
  cancel-in-progress: true

jobs:
  translate:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v5
        with:
          ref: ${{ github.event.client_payload.branch || github.head_ref || github.ref_name }}
          fetch-depth: 0

      - uses: erlef/setup-beam@v1
        with:
          elixir-version: "1.17"
          otp-version: "27"

      - name: Extract messages
        run: |
          mix deps.get
          mix gettext.extract --merge

      - uses: localheroai/localhero-action@v1
        with:
          api-key: ${{ secrets.LOCALHERO_API_KEY }}

fetch-depth: 0 is important: the Action diffs against the base branch, and a shallow checkout has nothing to compare. See the GitHub Actions guide for all inputs.

The extract step means a PR that only touches lib/ still gets its new messages translated. Prefer extracting locally before each PR? Drop that step and the lib/** trigger, and adjust the Elixir and OTP versions to match your project either way.

How it works

  1. You open a pull request that touches your templates, modules or catalogs.
  2. The workflow runs mix gettext.extract --merge to extract new messages into your .pot and .po files, then the GitHub Action diffs the branch against your base branch and translates only the messages that changed or have an empty msgstr. Updated and removed messages are synced to the Localhero.ai backend, so the web interface stays in sync with your codebase and PRs.
  3. It commits the updated .po files to the same PR, so the diff you review contains both the code and its translations.
  4. You review and edit the translations in the dashboard, and edits sync back to the branch.

Nothing in your application has to know Localhero.ai exists. The .po file is the integration, so your Gettext backend, your gettext/2 calls and your deploy stay as they are.

Rather not automatically translate on PRs? Run mix gettext.extract --merge then npx @localheroai/cli translate locally to fill in every missing message, and commit the result yourself. See the CLI reference.

Phoenix specifics

The gettext directory layout

The layout priv/gettext/<locale>/LC_MESSAGES/<domain>.po is understood as-is, with no configuration:

priv/gettext/
├── default.pot
├── errors.pot
├── sv/
│   └── LC_MESSAGES/
│       ├── default.po
│       └── errors.po
└── nb/
    └── LC_MESSAGES/
        ├── default.po
        └── errors.po

The directory above LC_MESSAGES is trusted as the locale, so custom codes like nb_NO are read as-is rather than skipped, no localeRegex needed. Multiple domains work too: the recursive **/*.po pattern picks up default.po, errors.po and anything else you have added.

Umbrella apps

In an umbrella project the catalogs live under the web app rather than the umbrella root. Point paths at that directory, for example apps/my_app_web/priv/gettext, and set the workflow's paths: filter to match. paths takes a list, so if more than one app in the umbrella has its own gettext directory you can list them all.

Excluding messages

Exclusions work per file rather than per message: use translationFiles.ignore with a glob. That maps neatly onto gettext, where a separate domain is already how you keep internal copy apart from user-facing copy. ignoreKeys targets individual keys and applies to YAML and JSON projects.

Renaming and removing messages

Rewording source text in gettext creates a new msgid rather than updating one, so renames happen more often here than with key-based formats. In the pull request flow this takes care of itself: messages removed in the diff are reported to the backend along with the rest of the changes. The manual push command is more conservative and never deletes, so outside a PR run push --prune --force to make the remote side match your files.

Plurals

Translations come back in each target language's own CLDR plural categories rather than a copy of the source language's set. A language like Polish gets its extra plural forms even when your English source only has a singular and a plural.

Questions we get

Do I still run mix gettext.extract myself?

Extraction stays Phoenix's own tooling, but the workflow above runs it for you. Run it locally instead if you prefer committing catalog changes yourself. Localhero.ai starts where that leaves off, translating the messages that are sitting in the .po files without a translation.

Can I translate some languages myself?

Yes. Turn off Auto-translate for that language in your project settings, and Localhero.ai writes nothing for it. The CLI reports those languages as skipped, and you keep them under your own process while the rest are translated automatically.

Will it translate my whole catalog on every PR?

No, only messages that changed or are missing. On a pull request the diff is against the PR's base branch. Locally, --changed-only compares against main unless translationFiles.baseBranch says otherwise.

How do I stop it running on a specific PR?

Add the skip-translation label. The Action also skips draft PRs and its own commits. The details are in the GitHub Actions guide.

More on the .po workflow in CI: translating .po files in CI.

Last updated

Redo att testa?

Kom igång på några minuter. Inget kreditkort behövs.