Hello Hanami friends! I’ve just put together built-in i18n support for Hanami.
i18n is an important feature of apps and I’m excited for us to make this as easy as possible for our users, while still providing all the flexibility people come to expect from Hanami.
You can check it out here: Add i18n integration by timriley · Pull Request #1562 · hanami/hanami
This will go out in our May release. I’d love your feedback to make sure this is as robust as possible!
Overview
Here we add a Hanami::Providers::I18n provider source, and register a corresponding i18n provider the app when (a) the "i18n" gem is in the bundle, and (b) a config/i18n/ directory exists.
The i18n provider loads translation files from config/i18n/, and registers an "i18n" component that provides access to those translations. This component provides the full expected I18n interface (#translate, #t, #localize, #l, etc.), but over an individual I18n backend loaded with only the translation files from the given app or slice. This allows for full isolation of translations by default.
The result is something like this:
# Gemfile
gem "i18n"
# config/i18n/en.yml
en:
greeting: Hello, %{name}!
# No other setup required!
Hanami.app["i18n"].t("greeting", name: "Alice") # => Hello, Alice!
Configuration
I’ve taken a novel approach for how we configure this.
i18n is implemented via a provider, because it needs to register a component in our container. Being a provider, this means it has its own settings, and will allow the user to configure those settings by creating their own provider file with a configure_provider(:i18n) block.
This is nice and flexible, but at the same time, i18n is a pretty basic feature and I can understand it would feel like too much friction to create a whole separate file just to configure it.
So we also allow configuration of i18n via the app and slice classes. The following settings are available:
module MyApp
class App < Hanami::App
config.i18n.default_locale = :es
config.i18n.available_locales = [:es, :en]
config.i18n.load_path += ["config/custom_translations/**/*.yml"]
end
end
When the i18n provider runs its prepare step, it will apply the i18n configs from the corresponding app/slice, provided the user hasn’t already explicitly configured these on the provider.
This approach allows for:
- The user to make basic i18n configuration directly in their app/slice, alongside other configs, without having to create a separate file for i18n.
- Custom i18n configs to done once in the app level, and thanks to our parent->child slice config inheritance, those same configs to take effect in all of the slices in the app.
For completeness, if you wanted to configure i18n directly for the provider, here’s how it would look for the same settings from above:
Hanami.app.configure_provider(:i18n) do
config.default_locale = :es
config.available_locales = [:es, :en]
config.load_path += ["config/custom_translations/**/*.yml"]
end
The settings we’ve looked at above are our basic i18n settings, and they apply specifically to our default i18n backend, which is I18n::Backend::Simple from the i18n gem. This backend loads its translations from files.
However, a user may want to configure their own custom backend, to load translations in another way. To do this, they must do so via a provider. This is because changing the backend is advanced usage, and a custom backend may have dependencies on other parts of the app (like when using e.g. database-backed translations), and provides are the right tool for managing this kind of dependency.
Changing the backend can look like this, for simpler backends:
Hanami.app.configure_provider(:i18n) do
# A backend with no heavy dependencies can be configured directly in the provider block
config.backend = MyBackend.new
end
Or like this, for heavier backends:
Hanami.app.configure_provider(:i18n) do
# Configure a backend with other app dependencies in a lifecycle hook
before(:start) do
# A database-backed backend
config.backend = MyDatabaseBackend.new("db.gateway")
end
end
Slice patterns
Like every part of Hanami, i18n supports a spectrum of slice use cases.
Full isolation
By default, every slice has its own distinct "i18n" component, loading translations from that slice only.
Full sharing
If you want to share translations across slices, however, you have a few different options. First, you can add "i18n" to the shared_app_component_keys:
module MyApp
class App < Hanami::App
config.shared_app_component_keys += ["i18n"]
end
end
This will see translations loaded from the top-level config/i18n/ only, and that same set of translations made available to all slices.
Partial sharing
Lastly, you can configure slices to have their separate translations, while also having a certain set of translations shared across all slices (e.g. for common UI messages).
To do this, you can use the inheritance of config.i18n settings, along with a combination of absolute and relative load paths.
When the i18n provider iterates over the load paths to load the translation files, it appends relative load path entries to the slice root, whereas with absolute entries, it will take the path as is and load files from there.
This allows the following:
# config/app.rb
config.i18n.load_path += [
root.join("config/i18n/**/*.yml").to_s,
]
In this arrangement, every slice will still load their own translations from config/i18n/, but also load the files from /path/to/your/app/config/i18n/.
Questions
Does the i18n gem really not provide isolated I18n interfaces?
Our Hanami::Providers::I18n::Backend exists because we want to provide a fully isolated I18n-compatible interface to the per-slice translations. We have to do this ourselves since the i18n gem backends themselves do not provide that standard interface. In the i18n gem, this is done by the top-level I18n module, which is global and AFAICT is not possible to “instantiate” for specific backends only).
Is this really the case? Have I missed some important part of the i18n gem?
How do we feel about config/i18n/ as the location to hold the translation files?
- What I like about it: it’s short, it nicely echoes the name of the provider & component (and gem!), and it feels like a good counterpart e.g. to
config/db/. - OTOH, something like
config/locales/is possibly more self-explanatory, and also matches Rails, so it may be less surprising in that way.
On balance, I’m inclined to keep config/i18n/, but I’m open to input!
Next steps
This is just the basic foundation of the i18n feature. As next steps (and in separate PRs), we will also need to provide:
- View helpers, to make it easy to use translations directly from views and templates
I think that’s probably our only important next step. For all other areas, users can access translations simply by injecting "i18n" as a dependency via Deps.