rosetta

A blazing fast internationalization (i18n) library with compile-time key lookup supporting YAML and JSON formats i18n internationalization translation localization luckyframework l10n locale language kemal
0.1.0 released
wout/rosetta
38 4 6
Wout

Rosetta

A blazing fast internationalization (i18n) library for Crystal with compile-time key lookup. You'll never have a missing translation in your app, ever again.

GitHub

Why use Rosetta?

You'll never have a missing translation

Rosetta is different from other internationalization libraries because it handles key lookup at compile-time rather than runtime. The significant advantage is that you'll be able to find missing translations - or typos in your locale keys - during development rather than after you've deployed your app. This is also true for translation keys in all additional locales.

You'll never have a missing interpolation

In Rosetta, interpolation keys are arguments to the translation method. So if you're missing an argument, the compiler will complain. The parser will also compare interpolation keys in additional locales to the ones found in the default locale, and complain if some are missing.

Rosetta is 10x faster than similar libraries

Benchmarking against other libraries which also use YAML or JSON files for locales, Rosetta is about 10x faster than any other one.

For simple translations:

crimson-knight/i18n.cr translation 147.72k (  6.77µs) (± 3.36%) 0.99kB/op 178.77× slower
     crystal-i18n/i18n translation   2.25M (443.68ns) (± 3.44%)  48.0B/op  11.05× slower
         syeopite/lens translation   1.10M (912.67ns) (± 7.10%)   176B/op  22.72× slower
          wout/rosetta translation  24.89M ( 40.17ns) (± 6.59%)   0.0B/op         fastest

For translations with interpolations:

crimson-knight/i18n.cr interpolation 145.50k (  6.87µs) (± 4.47%)  0.99kB/op  23.12× slower
     crystal-i18n/i18n interpolation 138.84k (  7.20µs) (± 4.16%)  2.05kB/op  21.23× slower
         syeopite/lens interpolation 314.68k (  3.18µs) (± 7.30%)    561B/op   9.29× slower
          wout/rosetta interpolation   2.95M (339.26ns) (± 7.17%)   80.0B/op         fastest

Rosetta is that much faster because a lot of the hard work happens at compile-time. But also because the majority of the data is immutable and stored on the stack rather than the heap, out of the scope of garbage collector.

Installation

  1. Add the dependency to your shard.yml:
dependencies:
  rosetta:
    github: wout/rosetta
  1. Run shards install

  2. Run bin/rosetta --init

  3. Require the shard

# src/shards.cr
require "rosetta"
  1. Include the Rosetta::Translatable mixin
# e.g. src/pages/main_layout.cr
include Rosetta::Translatable
  1. Localize your app
Rosetta.locale = :es

class Hello::ShowPage < MainLayout
  def content
    h1 r("welcome_message").t(name: "Brian") # => "¡Hola Brian!"
  end
end

Setup

The bin/rosetta --init command will generate the initial files to get started.

An initializer has the following content:

# config/rosetta.cr
require "rosetta"

Rosetta::DEFAULT_LOCALE = :en
Rosetta::AVAILABLE_LOCALES = %i[en]
Rosetta::Backend.load("config/rosetta")

An example locale file:

# config/rosetta/example.en.yml
en:
  welcome_message: "Hi %{name}!" 

Configuration

Configuration options are set as constants in your initializer file.

DEFAULT_LOCALE

Defines the default value if no locale is set. The default default locale is set to :en.

Rosetta::DEFAULT_LOCALE = "es-ES"

🗒️ Note: The default locale is used by the compiler to define the ruling set of locale keys. This means that, if one of the other available locales is missing some of the keys found in the default key set, the compiler will complain. So every available locale will need to have the exact same key set as the default locale.

AVAILABLE_LOCALES

Defines all the available locales, including the default locale. The default for this setting is %i[en].

Rosetta::AVAILABLE_LOCALES = %i[de en-GB en-US es nl]

FALLBACKS

TODO: Fallbacks still need to be implemented.

Usage

Locale files

Chop up your locale files and place them in subdirectories; organise them any way you prefer. Currently, Rosetta supports YAML and JSON files and you can mix formats together.

🗒️ Note: Beware, though, that there is a fixed loading order. JSON files are loaded first, then YAML files. So in the unlikely situation where you have the same key in a JSON and a YAML file, YAML will take precedence.

Lookup

Looking up translations is done with the find macro:

Rosetta.find("user.name")

This will return a struct containing all the translation data for the given key. To get the translation for the currently selected locale, call the l (localize) method:

Rosetta.find("user.name").t
# => "User name"

Optionally, you can call to_s or use the struct with string interpolation:

Rosetta.find("user.name").to_s
# => "User name"

"#{Rosetta.find("user.name")}"
# => "User name"

The translation struct also includes the Lucky::AllowedInTags module, so it works with Lucky templates as well, even without having to call t:

class Products::ShowPage < MainLayout
  def content
    h1 Rosetta.find(".heading")
  end
end

Wehn required, the translations for all locales can be accessed with the translations property:

Rosetta.find("user.first_name").translations
# => {en: "First name", nl: "Voornaam"}

If a different locale needs to be used in a specific place, use the with_locale method:

Rosetta.with_locale(:nl) do
  Rosetta.find("user.first_name").t
  # => "Voornaam"
end

Interpolations

Interpolations can be passed as arguments for the t (for localize) method:

Rosetta.find("user.welcome_message").t(name: "Ary")
# => "Hi Ary!"

Important to know here is that translations with interpolation keys will always require you to call the t method with the right number of interpolation keys, or the compiler will complain:

# user.welcome_message: "Hi %{name}!"
Rosetta.find("user.welcome_message").t

Error: wrong number of arguments for 'Rosetta::Locales::User_WelcomeMessage#t'
(given 0, expected 1)

Overloads are:
 - Rosetta::Locales::User_WelcomeMessage#t(name : String)
 - Rosetta::Locales::User_WelcomeMessage#t(values : NamedTuple(name: String))

This is to ensure you're not missing any interpolation values.

The raw, uninterpolated string, can be accessed with the raw method:

Rosetta.find("user.welcome_message").raw
# => "Hi %{name}!"

One final note on interpolations. The t method does not accept hashes, only arguments or a NamedTuple. For situations where you have to use a hash, there's the t_hash method:

Rosetta.find("user.welcome_message").t_hash({ :name => "Beta" })
# => "Hi Beta!"

However, this method is considered unsafe because the content of hashes can't be checked at compile-time. Only use it when there's no other way, and use it with care.

The Translatable mixin

This mixin makes it more convenient to work with translated values in your classes. Here's an example of its usage:

Rosetta.locale = :es

class User
  include Rosetta::Translatable

  def name_label
    r("user.name_label").t
  end
end

User.new.name_label
# => "Nombre"

The r macro essentially is an alias for the Rosetta.find macro.

Inferred locale keys make it even more concise. By omitting the prefix of the locale key and having the key start with a ., the key prefix will be derived from the current class name:

class User
  include Rosetta::Translatable

  def name_label
    r(".name_label").t # => resolves to "user.name_label"
  end
end

This also works with nested class names, for example:

  • User => "user"
  • Components::MainMenu => "components.main_menu"
  • Helpers::SiteSections::UserSettings => "helpers.site_sections.user_settings"

Using inferred locale keys has an added bonus. You don't need to think about how to organise your locale files. And it also makes finding your keys a lot easier.

Finally, in case you want to use another prefix for the current class, a constant can be used:

class User
  include Rosetta::Translatable

  ROSETTA_PREFIX = "guest"

  def name_label
    r(".name_label").t # => resolves to "guest.name_label"
  end
end

Just like the global Rosetta.find marco, interpolations are passed using the t method:

class User
  include Rosetta::Translatable

  def welcome_message
    r(".welcome_message").t(name: "Ary")
  end
end

The r macro uses Rosetta.find to get the translations for a given key at compile-time. Then the t method localizes the value at runtime.

Date, time and numeric localization

Localization instructions live under a the rosetta_localization namespace in the locale files. The initializer script will install the required files for you in order to be able to work with Rosetta.

Localized time

Similar to translations, localization formats are retrieved at compile-time and localized at runtime.

Rosetta.time.l(Time.local)
# => "Sun, 29 Aug 2021 18:30:57 +0200"

This will use the :default format to convert the given Time object. Another predefined format can be passed:

Rosetta.time(:short).l(Time.local)
# => "29 Aug 18:30"

For specific formats, a string can be passed as well:

Rosetta.time("%H:%M:%S").l(Time.local)
# => "18:30:57"

Localized date

Rosetta.date.l(Time.local)
# => "2021-08-29"

Or with a date-formatted tuple:

Rosetta.date.l({1991, 9, 17})
# => "1991-09-17"

Similar to the time macro, a predefined format can be passed:

Rosetta.date(:long).l(Time.local)
# => "August 29, 2021"

Or a completely custom format:

Rosetta.date("%Y").l(Time.local)
# => "2021"

Localized number

Number formats work the same as date and time formats.

Rosetta.number.l(123_456.789)
# => "123,456.79"

With a specific predefined format:

Rosetta.number(:custom).l(123_456.789)
# => "12 34 56.789"

Or with specific formatting options:

Rosetta.number.l(123_456.789, decimal_places: 6)
# => "123,456.789000"

🗒️ Note: In the background, Rosetta uses Crystal's native Number#format method and accepts the same parameters.

The Localizable mixin

Include this mixin anywhere you want to work with localized dates, times and numbers. Here's an example of its usage:

class User
  include Rosetta::Localizable

  def birthday
    r_date(:short).l(born_at)
  end
end

User.new.birthday
# => "Feb 20"

Similarly there are the r_time and the r_number macros for retrieval, returning a struct which accepts the l method for the value that needs to be localized.

Parser checks

After loading all locales, the parser does a series of checkes on the given set.

Check 1: presence of translations for all locales

If the full set of translations is missing for a locale in the configured AVAILABLE_LOCALES, the parser will raise an error similar to the following:

Error: Expected to find translations for:

  ‣ en
  ‣ nl
  ‣ fr

But missing all translations for:

  ‣ fr

Check 2: presence of ruling key set in all alternative locales

The DEFAULT_LOCALE will define the key set that should be present in every alternative locale. If keys are missing, you'll get an error like the one below:

Error: Missing keys for locale "nl":

  ‣ user.first_name
  ‣ user.gender.male
  ‣ user.gender.female
  ‣ user.gender.non_binary

Check 3: no additional keys in alternative locales

If any of the alternative locales has keys that aren't present in the key set of the DEFAULT_LOCALE, the parser will raise an error:

Error: The "nl" locale has unused keys:

  ‣ user.name
  ‣ user.date_of_birth

Check 4: interpolation keys are present in every translation

If a translation in the DEFAULT_LOCALE has one or more interpolation keys, then those interpolation keys should also be present in the alternative locales. If not, an error similar to the following will be raised:

Error: Some translations have mismatching interpolation keys:

  ‣ nl: message.welcome should contain "%{first_name}"
  ‣ nl: base.validations.min_max should contain "%{min}"
  ‣ nl: base.validations.min_max should contain "%{max}"
  ‣ fr: message.welcome should contain "%{first_name}"

To-do

  • [X] Add specs for the existing code
  • [X] Make settings accessible to the compiler
  • [X] Send default_locale and available_locales to the parser
  • [X] Implement key comparison between available locales in the parser
  • [X] Add compiler error messages for mismatching keys
  • [X] Implement inferred locale keys at macro level
  • [X] Interpolation (with %{} tag for interpolation keys)
  • [X] Check existence of interpolation keys in all translations at compile-time
  • [X] Translatable mixin
  • [X] Localization of numeric values
  • [X] Localization of date and time values
  • [X] Localizable mixin
  • [X] Locale exceptions
  • [X] Add setup scripts
  • [ ] Pluralization (with one/many/other/count/... convention)
  • [ ] Implement fallbacks

Development

TODO: Coming soon!

Documentation

Contributing

  1. Fork it (https://github.com/wout/rosetta/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Contributors

  • wout - creator and maintainer
rosetta:
  github: wout/rosetta
  version: ~> 0.1.0
License MIT
Crystal >= 1.0.0

Authors

Dependencies 1

Development Dependencies 2

  • ameba ~> 0.14
    {'github' => 'crystal-ameba/ameba', 'version' => '~> 0.14'}
  • spec_mirror~wout master
    {'branch' => 'master', 'github' => 'wout/spec_mirror.cr'}

Dependents 0

Last synced .
search fire star recently