i18n integration, ready for preview

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:

  1. The user to make basic i18n configuration directly in their app/slice, alongside other configs, without having to create a separate file for i18n.
  2. 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.

2 Likes

Hi @timriley,

thank you for making this possible in Hanami. As far as I can tell, the configuration as proposed will help us manage translations more clearly. What we did in our app (funny enough, it’s a translation tool :slight_smile: ) based on this post and came up with simple config across the entire project in “our_app → config → initializers → i18n.rb” with basic settings as you specified above.

Then we have yml files in “our_app → config → locale”. The tricky part is that we it gets difficult to manage all the texts just by file names for each app and by key names within the yml files. So we have admin_en.yml and admin_sl.yml. Within the files themselves we manage texts by template names and text types.

The initial setup is not problematic, managing everything for all apps however turned out to be quite complex. Separation by slices would help with this regard for sure.

EDIT:

For reference: our app is still at Hanami 1.3.

Thank you Tim for your work! I’ll try it this week.

About the location of the locale files, config/locales seems more natural, because we’re talking about data. i18n is a concept but also the name of the gem, but locales are what I’ll found in the folder. But I guess we’d be able to configure it anyway?

By the way I like your double approach with a simple settings, that can be overriden by a full provider :hugs:

I’ll let you know more when I’ll try it for real

Thanks folks for checking this out!

I’ve just added support for fallbacks, and also merged the PR.

I haven’t changed anything about the config/i18n/ folder naming. Have a go with using it and see how it sits with you. It is configurable, however, just change the load_path to ["config/my_cool_translations/**/*.{yml,yaml,json,rb}"] and you’ll be golden :slight_smile:

I’ll leave another update here when we’ve added extra support for this within the view layer.

It works! (the most important thing!)

I would prefer if it was in config/locales by default though as I rarely am thinking ‘look in some i18n folder’. locales just tends to fit the mental model for my brain across ruby / javascript frameworks

1 Like