Replace Generic UI Copy with Your User's Domain-Specific Terms Using Gettext Domains
Welcome everyone to the new year! Let’s start it the right way with a joke:
What do you call a fruit that magically shows up?
A pear
Now, let’s dive into today’s topic: How to white-label a software using Gettext domains.
The Problem
Imagine you develop a white-label HR software and you want to customise the way your software addresses the “user” depending on the company they work for. Most companies address their workers as “Employees”, but some fancy tech start-ups like to call them “Googlers”, “Metamates”, “Tweeps”, or “Amazonians”. That’s why your boss gives you the wonderful “opportunity” to replace all Employee
, Employees
, Employee's
, employee
, etc. occurrences with customised terms and to build a solution that will make it easier to onboard future clients.
Your first thought might be to build the quick-and-dirty solution of adding if-else
statements everywhere. That closes the ticket quicker and impresses your manager, but it also makes it a pain in the butt to add new text, to maintain your templates, and generally just feels wrong. Since you’re an experienced engineer, you know that quick-and-dirty solutions are often quick-and-thoughtless solutions, which get you only so far before your house of cards collapses. So, you halt and take a minute to think of a better solution.
The (better) Solution
After a few minutes of thinking, you realise that you’re trying to reinvent Gettext, which replaces strings in your templates based on a user’s locale. In your case, you need to replace the strings based on the user’s employer. You skim the Gettext docs and realise that Gettext Domains is exactly what you want. You could define default strings like Employee
and let Gettext replace them with a domain-specific “translation” based on the user’s company.
Your first implementation is simple. All you need to do is to make the following change anywhere you wrote e.g. Employee
:
# e.g. page.html.heex
- <h1>Welcome {gettext("Hello Employee")}</h1>
+ <h1>Welcome {dgettext(@domain, "Hello Employee")}</h1>
Next, you create a new PO
file for each company at priv/gettext/en/LC_MESSAGES/*
, for example meta.po
, and add the following translation to it:
# priv/gettext/en/LC_MESSAGES/meta.po
msgid "Hello Employee"
msgstr "Hello Metamate"
Now, when you open your website as a Meta employee, you should be greeted with “Hello Metamate”. It works! Hurray!
The (even better) Solution
While applying the change described above, you realise how tedious and error-prone it is to manually add all occurrences of Employee
to your PO
file. You think: There must be a better way!
and you’d be right!
The big problem with the solution above is that Gettext won’t automatically extract the domain-specific strings from your template and add them to your PO file, like it does with e.g. gettext/1
calls. The problem is that you call dgettext/2
with a dynamic domain at runtime and Gettext extracts the strings at compile-time. So, Gettext doesn’t know for which domains it should extract the string. Thankfully, there’s an easy solution for this problem.
The smart minds behind Gettext have thought about this situation and added the dgettext_noop/2 macro. This macro manually marks a string for extraction and instructs Gettext to extract it when you run mix gettext.extract
.
Using the macro isn’t as easy as calling dgettext/2
in your template, but it’s not rocket science either. Have a look at the following macro, but don’t be sad if you don’t understand it, because I don’t either (but it works!)
# demo_web/domains.ex
defmodule DemoWeb.Domains do
require Gettext.Macros
@domains ["default", "meta", "google"]
defmacro dtext(domain, message) do
noops =
for d <- unquote(@domains) do
quote do
Gettext.Macros.dgettext_noop_with_backend(DemoWeb.Gettext, unquote(d), unquote(message))
end
end
quote do
unquote(noops)
Gettext.dgettext(DemoWeb.Gettext, unquote(domain), unquote(message))
end
end
end
I wrote this macro based on a suggestion by Kip on Elixirforum and got it working with the kind help of Benjamin (LostKobrakai). In essence, it marks a message for extraction at compile-time for all defined domains and calls dgettext/3
at runtime, just as before. There’s no impact on our rendering performance either, because the dgettext_noop_with_backend/3
functions are only called at compile-time and not at runtime. So, for our runtime, they don’t even exist.
You can make the new dtext/2
function available to all templates by importing the DemoWeb.Domains
module in your *Web
context:
# demo_web.ex
defp html_helpers do
quote do
# Removed for brevity ...
use Gettext, backend: DemoWeb.Gettext
# 👇 Import the Domains module here
import DemoWeb.Domains
end
end
Next, instead of calling dgettext/2
in your templates directly, call dtext/2
instead:
# home.html.heex
<h1>{dtext(@domain, "Hello Employee")}</h1>
<h2>{gettext("Hello World")}</h2>
Now, run the following command and Gettext will extract all messages to your domain PO files!
mix gettext.extract --merge
You should now see the following content in your three PO files:
# priv/gettext/en/LC_MESSAGES/default.po
msgid "Hello Employee"
msgstr ""
msgid "Hello World"
msgstr ""
# priv/gettext/en/LC_MESSAGES/meta.po
msgid "Hello Employee"
msgstr "Hello Metamate"
# priv/gettext/en/LC_MESSAGES/google.po
msgid "Hello Employee"
msgstr "Hello Googler"
Awesome! Gettext only extracted the domain-specific strings to your domain PO files, which makes it easier to translate them for each company. Gettext also extracted the string to default.po
which serves as a fallback. It also extracted all other gettext/1
calls as expected, but only to the default.po
file, so that non domain-specific strings won’t clutter your domain PO files.
Conclusion
Congratulations! You’ve resisted the junior urge to immediately run with the first solution you can think of just to feel like you’re moving, regardless of whether the direction you move in is even remotely desirable. You found an elegant, simple, and extendable solution and even learned something new about the language you work with, which makes you a better engineer! Good job!
And that’s it! I hope you enjoyed this article! If you want to support me, you can buy my firewall for Phoenix Phx2Ban or my book or video courses (one and two). Follow me on Bluesky or subscribe to my newsletter below if you want to get notified when I publish the next blog post. Until next time! Cheerio 👋