Packages
ash_gettext
0.1.0
Translations for Ash apps, with Gettext. Your text, your enum values and Ash's error messages come out in each user's language.
Current section
Files
Jump to
Current section
Files
ash_gettext
README.md
README.md
# AshGettext
[](https://github.com/Undeadlol1/ash_gettext/actions/workflows/ci.yml)
[](https://github.com/Undeadlol1/ash_gettext/blob/main/LICENSE)
A translation library for [Ash](https://hexdocs.pm/ash). Your text, your
enum values, your attribute names and Ash's own errors come out in each
user's language.
## TL;DR
```elixir
defmodule MyApp.Order.Status do
use AshGettext.Enum,
backend: MyApp.Gettext,
values: [placed: [label: "Order placed"], awaiting_pickup: [label: "Ready for pickup"]]
end
# `mix gettext.extract` collects the labels you wrote into your template.
# You translate them in `priv/gettext/ru/LC_MESSAGES/ash.po`.
order_for_russian_user = Ash.get!(MyApp.Order, id, load: [:status_label], actor: russian_user)
order_for_english_user = Ash.get!(MyApp.Order, id, load: [:status_label], actor: english_user)
order_for_russian_user.status_label #=> "Ожидает выдачи"
order_for_english_user.status_label #=> "Ready for pickup"
# The resource says: validate present(:delivery_date), message: "can't be blank"
{:error, error_for_russian_user} = Ash.create(MyApp.Order, %{delivery_date: nil}, actor: russian_user)
{:error, error_for_english_user} = Ash.create(MyApp.Order, %{delivery_date: nil}, actor: english_user)
hd(error_for_russian_user.errors).message #=> "не может быть пустым"
hd(error_for_english_user.errors).message #=> "can't be blank"
```
Every enum attribute gets a translated `_label` field that clients can load. Errors come back in the user's language. The English you write is the key. That's it.
## Installation
Not on Hex yet, so take it from GitHub:
```elixir
def deps do
[
{:ash_gettext, github: "Undeadlol1/ash_gettext"}
]
end
```
Your app needs a Gettext backend (`use Gettext.Backend, otp_app: :my_app`).
## Usage
### Set it up once
Add the extension and a `gettext` block to each domain:
```elixir
defmodule MyApp.Shop do
use Ash.Domain, extensions: [AshGettext.Domain]
gettext do
backend(MyApp.Gettext)
locale_resolver(MyApp.LocaleResolver)
end
end
```
Add the extension to each resource. It gives the resource its `_label`
fields, its attribute names and its translated errors.
```elixir
defmodule MyApp.Order do
use Ash.Resource,
domain: MyApp.Shop,
extensions: [AshGettext.Resource]
attributes do
uuid_primary_key(:id)
attribute(:status, MyApp.Order.Status, public?: true)
end
end
```
Or let the install task add both to every domain and resource. The first
command shows the edits, the second writes them:
```
mix ash_gettext.install
mix ash_gettext.install --apply
```
To write all this once instead of in every domain and resource, see
[Set it up once for the whole app](documentation/topics/set-up-once.md).
### Tell it the user's language
A resolver is your code. It gets the actor and returns its language. Here
the user has a `:language` field:
```elixir
defmodule MyApp.LocaleResolver do
@behaviour AshGettext.Locale.Resolver
@impl true
def resolve(%{language: language}), do: language
def resolve(_actor), do: nil
end
```
Call the field whatever you like; the resolver is where you say it. One
resolver is enough for the whole app; list it in every domain (or your base
domain).
Now run any action with `actor: user`. Labels loaded on records and errors
come out in the actor's language. The process language is never changed.
Code outside an action, such as a job that formats text, picks the language
with `Gettext.with_locale/3`.
A resolver must be a pure function of the actor: read the language off the
loaded actor, do not query the database. `ru_RU` and `en-US` are cut to the
language, and a language with no catalog becomes `default_locale`. More in
[Locales](documentation/topics/locales.md).
### Translate an enum
Write a label for every value. AshGettext never makes a label from a code
name, so a value without one fails the build. `use AshGettext.Enum` takes the
values and makes the Ash enum for you:
```elixir
defmodule MyApp.Order.Status do
use AshGettext.Enum,
backend: MyApp.Gettext,
values: [
placed: [label: "Order placed"],
awaiting_pickup: [label: "Ready for pickup", description: "Ready at the pickup point"]
]
end
```
```elixir
test "the status reads in Russian" do
Gettext.put_locale(MyApp.Gettext, "ru")
assert MyApp.Order.Status.label(:awaiting_pickup) == "Ожидает выдачи"
assert MyApp.Order.Status.description(:awaiting_pickup) == "Готов к выдаче в пункте"
end
```
`label/1` and `description/1` are still Ash's own functions. They now return
the translated text, in Gettext's current language. An empty value (`nil`) gives `nil`.
### Show an enum on a record
Every attribute whose type uses `AshGettext.Enum` gets a field named
`<attribute>_label`. Load it like any calculation:
```elixir
order = Ash.get!(MyApp.Order, id, load: [:status_label], actor: user)
order.status #=> :awaiting_pickup
order.status_label #=> "Ожидает выдачи"
```
An empty value gives `nil`, a list attribute gives a list of labels, and a
field you already named `status_label` is left alone.
### Show an attribute name
Write the name in the resource:
```elixir
gettext do
label :date_of_birth, "Date of birth"
end
```
```elixir
test "the attribute name reads in Russian" do
Gettext.put_locale(MyApp.Gettext, "ru")
assert AshGettext.attribute_label(MyApp.Order, :date_of_birth) == "Дата рождения"
end
```
An attribute with no written `label` raises `ArgumentError`. A `label` for an
attribute the resource does not have fails the build.
### Errors
Ash's own errors come back in the user's language. Leave out a required
value and the user reads a sentence, not a field name:
```elixir
{:error, error} = Ash.create(MyApp.Order, %{}, actor: user)
hd(error.errors).message
#=> "обязательное поле" (Ash says "attribute delivery_date is required")
```
The English sentence is "is required". `mix gettext.extract` adds it and
the other sentences for Ash's errors to your template.
Your own messages work the same way. Write them in your own language:
```elixir
validate present(:email), message: "can't be blank"
```
```elixir
{:error, error} = Ash.create(MyApp.User, %{email: nil}, actor: user)
hd(error.errors).message
#=> "не может быть пустым"
```
No code on the action. Create, update and destroy errors are translated.
### Hand the texts to translators
```
mix gettext.extract --merge # Gettext's own command
mix gettext.extract --check-up-to-date # in CI: fails when a template is stale
```
AshGettext makes your enum labels, attribute labels, messages and Ash's error
sentences visible to it, so they land in `ash.pot`. Translators fill in
`ash.po` per language.
### Catch missing translations
A lookup with no translation returns the text as written and sends a
telemetry event. Log it:
```elixir
:telemetry.attach(
"ash-gettext-missing",
[:ash_gettext, :translation, :missing],
&MyApp.Telemetry.log_missing/4,
nil
)
def log_missing(_event, _measurements, meta, _config),
do: Logger.warning("No #{meta.locale} translation for #{inspect(meta.msgid)}")
```
The metadata has `backend`, `text_domain`, `msgid` and `locale`.
## Learn more
- [Translating text](documentation/topics/translating-text.md): enums,
strings, plurals and attribute names.
- [Locales](documentation/topics/locales.md): where the user's language comes
from, and how it is matched to your catalogs.
- [Translating errors](documentation/topics/translating-errors.md): which
errors are translated, coded errors, and turning it on one domain at a time.
- [Extracting messages](documentation/topics/extracting-messages.md): what
`mix gettext.extract` finds, and checking it in CI.
- [How to test with AshGettext](documentation/topics/testing.md): check that
an action returns the translated text.
- [How AshGettext works](documentation/topics/how-it-works.md): the path of a
lookup, the path of an error, and the limits.
- [DSL reference](documentation/dsls/DSL-AshGettext.Domain.md): every option
in the `gettext` block.
## Limits
- **Only create, update and destroy errors are translated.** Ash gives read
and generic actions no error handler, so their errors arrive as written.
- **Text passed to `AshGettext.translate/5` is not extracted.** `mix gettext.extract`
does not see it. Add it to the
template by hand, or write it with a Gettext macro. Enum labels, attribute
labels and `message:` strings are extracted.
- **A message built with `#{...}` cannot be translated.** Write `%{name}` in
the text and pass the value instead.
- **A handler you wrote is kept.** An action with its own `error_handler`
is not translated.
## For AI assistants
<details>
<summary>Instructions for coding agents (Claude Code, Cursor, Copilot, ...)</summary>
<p>
The file for agents is
<a href="https://github.com/Undeadlol1/ash_gettext/blob/main/usage-rules.md"><code>usage-rules.md</code></a>.
It ships inside the package, so after <code>mix deps.get</code> it is at
<code>deps/ash_gettext/usage-rules.md</code>. Point your agent at that file.
If your project uses the
<a href="https://hexdocs.pm/usage_rules"><code>usage_rules</code></a>
package, add <code>:ash_gettext</code> to its list instead.
</p>
</details>
## License
MIT. See [LICENSE](https://github.com/Undeadlol1/ash_gettext/blob/main/LICENSE).