Skip to content

Commit

Permalink
i18n refactoring:
Browse files Browse the repository at this point in the history
- added multi-language
- simpler I18N format
- faster pagy_t method
  • Loading branch information
ddnexus committed Feb 22, 2019
1 parent 1f3c86f commit 9c1212b
Show file tree
Hide file tree
Showing 13 changed files with 218 additions and 118 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Please check the [Benchmarks and Memory Profiles Source](http://github.com/ddnex

- Pagy has a very slim core code of just ~100 line of simple ruby, organized in 3 flat modules very easy to understand and use _(see [more...](https://ddnexus.github.io/pagy/api))_
- It has a quite fat set of optional extras that you can explicitly require for very efficient and modular customization _(see [extras](https://ddnexus.github.io/pagy/extras))_
- It has no dependencies: it produces its own HTML, URLs, pluralization and interpolation with its own specialized and fast code _(see [why...](https://ddnexus.github.io/pagy/index#specialized-code-instead-of-generic-helpers))_
- It has no dependencies: it produces its own HTML, URLs, i18n with its own specialized and fast code _(see [why...](https://ddnexus.github.io/pagy/index#specialized-code-instead-of-generic-helpers))_
- 100% of its methods are public API, accessible and overridable **right where you use them** (no need of monkey-patching)
- 100% test coverage for core code and extras

Expand Down
100 changes: 48 additions & 52 deletions docs/api/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,84 +160,73 @@ This method is similar to the `I18n.t` and its equivalent rails `t` helper. It i

**IMPORTANT**: if you are using pagy with some language missing from the [dictionary files](https://github.com/ddnexus/pagy/blob/master/lib/locales), please, submit your translation!

Pagy is I18n ready. That means that all its strings are stored in a dictionary file of one of its [languages](https://github.com/ddnexus/pagy/blob/master/lib/locales), ready to be customized and/or translated/pluralized and used with or without the `I18n` gem.
Pagy is i18n ready. That means that all its strings are stored in the dictionary files of its [locales](https://github.com/ddnexus/pagy/blob/master/lib/locales), ready to be customized and/or used with or without the `I18n` gem.

A Pagy dictionary file is a YAML file containing a few entries used in the the UI by helpers and templates through the [pagy_t method](#pagy_tpath-vars) (eqivalent to the `I18n.t` or rails `t` helper). The file follows the same structure of the standard locale files for `i18n`.
**Notice**: a Pagy dictionary file is a YAML file containing a few entries used internally in the the UI by helpers and templates through the [pagy_t](#pagy_tpath-vars) method. The file follows the same structure of the standard locale files for the `i18n` gem.

### Multi-language apps
### Pagy I18n implementation

For multi-language apps you need the dynamic translation provided by the [i18n extra](../extras/i18n.md), which delegates the handling of the pagy strings to the `I18n` gem. In that case you need only to require the I18n extra in the initializer file.
The pagy internal i18n implementation is ~12x faster and uses ~6x less memory than the standard `i18n` gem.

**Notice**: For simplicity, you could also use the `i18n` extra for single-language apps, but if you want more performance, please follow the specific documentation below.
Since Pagy version 2.0, you can use it for both single-language and multi-language apps, with or without the `i18n` gem.

### Single-language apps
Notice: if your app is using i18n, it will work independently from it.

Single-language apps (i.e. only `fr` or only `en` or only ...) don't need to switch between languages, so they don't need the `i18n` extra/`I18n` gem (although you could choose to use it).
The pagy internal i18n is implemented around the `Pagy::I18n` constant hash which contains the locales data needed to pagy and your app. You may need to configure it in the [pagy.rb](https://github.com/ddnexus/pagy/blob/master/lib/config/pagy.rb) initializer.

By default, Pagy handles its own dictionary file directly, providing pluralization and interpolation (without dynamic translation) _5x faster_ and using _3.5x less memory_ than the standard `I18n` gem.
#### Pagy::I18n.load configuration

If you are fine with the locales provided with pagy, you just need to load the dictionary file of your language by adding this line the initializer file. For example with `zh-cn`:
By default pagy will render its output using the built-in `en` locale. If your app uses only `en` and you are fine with the built-in strings, you are done without configuring anything at all.

```ruby
Pagy::Frontend::I18N.load(file: Pagy.root.join('locales', 'zh-cn.yml'), language:'zh-cn')
```

If you need to use your own translation file and/or customize the Pagy strings in this scenario, you may need the following steps:

1. copy and edit one of the [dictionary files](https://github.com/ddnexus/pagy/blob/master/lib/locales)
2. load it in the initializer file (e.g. `Pagy::Frontend::I18N.load(file:..., language:'tr')`
3. see [Adding the model translations](#adding-the-model-translations) below
4. check if you need to configure some of the following variables in the [pagy.rb](https://github.com/ddnexus/pagy/blob/master/lib/config/pagy.rb) initializer.

#### Pagy::Frontend::I18N Constant
If you need to load different built-in locales, and/or custom dictionary files or even non built-in languages and pluralizations, you can do it all by passing a few arguments to the `Pagy::I18n.load` method.

**IMPORTANT**: This variable has no effect if you use the `i18n` extra.
**Notice**: the `Pagy::I18n.load` method is intended to be used once in the [pagy.rb](https://github.com/ddnexus/pagy/blob/master/lib/config/pagy.rb) initializer. If you use it multiple times, the last statement will override the previous statements.

The `Pagy::Frontend::I18N` constant is the core of the Pagy I18n implementation. This constant allows to control the dictionary file, the language to load and the pluralization proc.
Here are a few examples that should cover all the possible confgurations:

#### Pagy::Frontend::I18N.load(file:..., language:'en')
```rb
# IMPORTANT: use only one load statement
**IMPORTANT**: This method has no effect if you use the `i18n` extra.
# load the "de" built-in locale:
Pagy::I18n.load(locale: 'de')
It allows to load a built-in language (different than the default 'en') and/or a custom dictionary file, different from `Pagy.root.join('locales', 'pagy.yml')`. It is tipically used in the Pagy initializer file _(see [Configuration](../how-to.md#global-configuration))_. For example:
# load the "de" locale defined in the custom file at :filepath:
Pagy::I18n.load(locale: 'de', filepath: 'path/to/pagy-de.yml')
```ruby
# this would load the Italian variant of the built-in dictionary
Pagy::Frontend::I18N.load(language:'it')
# this would load the default English variant of 'path/to/dictionary.yml'
Pagy::Frontend::I18N.load(file:'path/to/dictionary.yml')
# this would load the Italian variant of 'path/to/dictionary.yml'
Pagy::Frontend::I18N.load(file:'path/to/dictionary.yml', language:'it')
# load the "de", "en" and "es" built-in locales:
# the first :locale will be used also as the default_locale
Pagy::I18n.load({locale: 'de'},
{locale: 'en'},
{locale: 'es'})
# load the "en" built-in locale, a custom "es" locale, and a totally custom locale complete with the :pluralize proc:
Pagy::I18n.load({locale: 'en'},
{locale: 'es', filepath: 'path/to/pagy-es.yml'},
{locale: 'xyz', # not built-in
filepath: 'path/to/pagy-xyz.yml',
pluralize: lambda{|count| ... } )
```

**Notice**: the Pagy implementation of I18n is designed to speedup single-language apps and does not provide dynamic translation, so the `language` is statically loaded at startup-time and cannot be changed. Use the `i18n` extra if you need dynamic translation.

#### Pagy::Frontend::I18N[:plural]
**Notice**: You should use a custom `:pluralize` proc only for pluralization types not included in the built-in [p11n.rb](https://github.com/ddnexus/pagy/blob/master/lib/locales/utils/p11n.rb)
rules. In that case, please submit a PR with your dictionary file and plural rule. The `:pluralize` proc should receive the `count` as a single argument and should return the plural type string (e.g. something like `'zero'`, `'one'` or `'other'`, depending on the passed count).

**IMPORTANT**: This variable has no effect if you use the `i18n` extra.
#### Set the request locale in multi-language apps

This variable controls the internal pluralization.
When you configure multiple locales, you must also set the locale for each request. You usually do that in the application controller, by checking the `:locale` param. For example, in a rails app you should do something like:

Pagy tries to set the language plural proc when you use the `Pagy::Frontend::I18N.load` method, by loading the built-in plural for the language. _(see [plurals.rb](https://github.com/ddnexus/pagy/blob/master/lib/locales/plurals.rb))_

If there is no rule defined for the language loaded, the variable is set to the `:zero_one_other` plural rule (default for English language).

If your custom language requires a pluralization different than `:zero_one_other`, you should define a custom rule. For example:

```ruby
# this would apply a custom pluralization rule to the current loaded dictionary
Pagy::Frontend::I18N[:plural] = -> (count) {|count| ...}
```rb
before_action { @pagy_locale = params[:locale] || 'en' }
```

The custom proc should receive the `count` as a single argument and should return the plural type string (e.g. something like `'zero'`, `'one'` or `'other'`, depending on the passed count). You should customize it only for pluralization types not included in the built-in plural rules. In that case, please submit a PR with your dictionary file and plural rule. Thanks.
That instance variable will be used by the [pagy_t](#pagy_tpath-vars) method included in your view and will translate the pagy strings to the selected locale.

**Notice**: In case of `@pagy_locale.nil?` or unknown/not-loaded, then the first loaded locale will be used for the translation. That means that you don't have to set the `@pagy_locale` variable if your app uses just a single locale.
#### Adding the model translations
When Pagy uses its own handling of the dictionary file, it has only access to the strings in its own file and not in other `I18n` files used by the rest of the app.
When Pagy uses its own i18n implementation, it has only access to the strings in its own files and not in other `I18n` files used by the rest of the app.
That means that if you use the `pagy_info` helper with the specific model names instead of the generic "items" string, you may need to add entries for the models in the pagy dictionary file. For example:
That means that if you use the `pagy_info` helper with the specific model names instead of the generic "items" string, you may need to add entries for the models in the pagy dictionary files. For example:
```yaml
en:
Expand All @@ -255,3 +244,10 @@ en:
```
_(See also the [pagy_info method](#pagy_infopagy))_
### Using the I18n gem
If - despite the disadvantages - you want to use the standard `i18n` gem in place of the pagy i18n implementation, you should use the [i18n extra](../extras/i18n.md), which delegates the handling of the pagy strings to the `i18n` gem. In that case you need only to require the extra in the initializer file with `require 'pagy/extras/i18n'` and everything will be handled by the `i18n` gem.
**Notice**: if you use the [i18n extra](../extras/i18n.md)/`i18n` gem, you don't need any of the above configurations.
6 changes: 5 additions & 1 deletion docs/extras/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ title: I18n
---
# I18n Extra

The `i18n` extra overrides the `pagy_t` method so it uses `I18n.t`. Use this extra only with multi-language apps since the `I18n` gem adds quite an overhead and slowers down Pagy.
**Notice**: Since Pagy version 2.0, you can use the pagy `i18n` implementation for both single-language and multi-language apps, with or without the `i18n` gem.

The `i18n` extra overrides the `pagy_t` method so it uses `I18n.t` implemented by the `i18n` gem.

The `I18n.t` is ~12x slower and uses ~6x more memory than `pagy_t` so use it wisely.

See also [I18n](../api/frontend.md#i18n).

Expand Down
2 changes: 1 addition & 1 deletion docs/how-to.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ By default Pagy generates all the page links including the `page` param. If you

## Using Templates

The `pagy_nav*` helpers are optimized for speed, and they are really fast. On the other hand editing a template might be easier when you have to customize the rendering, however every template system adds some inevitable overhead and it will be about 40-80% slower than using the related helper. That will still be dozens of times faster than the other gems, but... you should choose wisely.
The `pagy_nav*` helpers are optimized for speed, and they are really fast. On the other hand editing a template might be easier when you have to customize the rendering, however every template system adds some inevitable overhead and it will be about 30-70% slower than using the related helper. That will still be dozens of times faster than the other gems, but... you should choose wisely.

Pagy provides the replacement templates for the `pagy_nav`, `pagy_bootstrap_nav`, `pagy_bulma_nav` and the `pagy_foundation_nav` helpers (available with the relative extras) in 3 flavors: `erb`, `haml` and `slim`.

Expand Down
39 changes: 29 additions & 10 deletions lib/config/pagy.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Pagy initializer file
# Customize only what you really need but notice that Pagy works also without any of the following lines.
# Customize only what you really need and notice that Pagy works also without any of the following lines.


# Extras
Expand Down Expand Up @@ -111,15 +111,34 @@

# I18n

# I18n faster internal pagy implementation (does not use the I18n gem)
# Use only for single language apps that don't need dynamic translation between multiple languages
# Pagy internal I18n: ~12x faster using ~6x less memory than the i18n gem
# See https://ddnexus.github.io/pagy/api/frontend#i18n
# Notice: Do not use any of the following lines if you use the i18n extra below
# Pagy::Frontend::I18N.load(file: Pagy.root.join('locale', 'es.yml'), language:'es') # load 'es' pagy language file
# Pagy::Frontend::I18N.load(file:'path/to/dictionary.yml', language:'en') # load a custom 'en' file
# Pagy::Frontend::I18N[:plural] = -> (count) {(['zero', 'one'][count] || 'other')} # default

# I18n extra: Use the `I18n` gem instead of the pagy implementation
# (slower but allows dynamic translation between multiple languages)
# Notice: No need to use any of the following lines if you use the i18n extra below
#
# Examples:
# load the "de" built-in locale:
# Pagy::I18n.load(locale: 'de')
#
# load the "de" locale defined in the custom file at :filepath:
# Pagy::I18n.load(locale: 'de', filepath: 'path/to/pagy-de.yml')
#
# load the "de", "en" and "es" built-in locales:
# (the first passed :locale will be used also as the default_locale)
# Pagy::I18n.load({locale: 'de'},
# {locale: 'en'},
# {locale: 'es'})
#
# load the "en" built-in locale, a custom "es" locale,
# and a totally custom locale complete with the :pluralize proc:
# (the first passed :locale will be used also as the default_locale)
# Pagy::I18n.load({locale: 'en'},
# {locale: 'es', filepath: 'path/to/pagy-es.yml'},
# {locale: 'xyz', # not built-in
# filepath: 'path/to/pagy-xyz.yml',
# pluralize: lambda{|count| ... } )


# I18n extra: uses the standard i18n gem which is ~12x slower using ~6x more memory
# than the default pagy internal i18n (see above)
# See https://ddnexus.github.io/pagy/extras/i18n
# require 'pagy/extras/i18n'
17 changes: 17 additions & 0 deletions lib/locales/utils/i18n.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# See https://ddnexus.github.io/pagy/api/frontend#i18n
# frozen_string_literal: true

# this file returns the I18n hash used as default alternative to the i18n gem

Hash.new{|h,_| h.first[1]}.tap do |i18n| # first loaded locale used as default
i18n.define_singleton_method(:load) do |*args|
# eval: we don't need to keep the loader proc in memory
eval(Pagy.root.join('locales', 'utils', 'loader.rb').read).call(i18n, *args) #rubocop:disable Security/Eval
end
i18n.define_singleton_method(:t) do |locale, path, vars={}|
data, pluralize = self[locale]
translate = data[path] || vars[:count] && data[path+=".#{pluralize.call(vars[:count])}"] or return %([translation missing: "#{path}"])
translate.call(vars)
end
i18n.load(locale: 'en')
end
28 changes: 28 additions & 0 deletions lib/locales/utils/loader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

# the whole file will be eval'ed/executed and gc-collected after returning/executing the loader proc

# eval: no need for the whole file in memory
p11n = eval(Pagy.root.join('locales', 'utils', 'p11n.rb').read) #rubocop:disable Security/Eval

# flatten the dictionary file nested keys
# convert each value to a simple ruby interpolation proc
flatten = lambda do |hash, key=''|
hash.each.reduce({}) do |h, (k, v)|
v.is_a?(Hash) \
? h.merge!(flatten.call(v, "#{key}#{k}."))
: h.merge!(eval %({"#{key}#{k}" => lambda{|vars|"#{v.gsub(/%{[^}]+?}/){|m| "\#{vars[:#{m[2..-2]}]||'#{m}'}" }}"}})) #rubocop:disable Security/Eval
end
end

# loader proc
lambda do |i18n, *args|
i18n.clear
args.each do |arg|
arg[:filepath] ||= Pagy.root.join('locales', "#{arg[:locale]}.yml")
arg[:pluralize] ||= p11n[arg[:locale]]
hash = YAML.load(File.read(arg[:filepath], encoding: 'UTF-8')) #rubocop:disable Security/YAMLLoad
hash.key?(arg[:locale]) or raise ArgumentError, %(Pagy::I18n.load: :locale "#{arg[:locale]}" not found in :filepath "#{arg[:filepath].inspect}")
i18n[arg[:locale]] = [flatten.call(hash[arg[:locale]]), arg[:pluralize]]
end
end
31 changes: 16 additions & 15 deletions lib/locales/plurals.rb → lib/locales/utils/p11n.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
# This file adds support for multiple built-in plualization types.
# It defines the pluralization procs and gets eval(ed) at I18N.load time.
# See https://ddnexus.github.io/pagy/api/frontend#i18n
# frozen_string_literal: true

# This file adds support for multiple built-in plualization types.
# It defines the pluralization procs and gets eval(ed) and gc-collected at Pagy::I18n.load time.

# utility variables
zero_one = ['zero', 'one'].freeze
from2to4 = (2..4).freeze
from5to9 = (5..9).freeze
from11to14 = (11..14).freeze
from12to14 = (12..14).freeze

# Plurals
# A plural proc returns a plural type string based on the passed count
# Each plural proc may apply to one or more languages below
plurals = {
# Pluralization (p11n)
# A pluralization proc returns a plural type string based on the passed count
# Each proc may apply to one or more locales below
p11n = {
zero_one_other: lambda {|count| zero_one[count] || 'other'},

zero_one_few_many_other: lambda do |count|
Expand All @@ -36,14 +38,13 @@
end
}

# Languages (language/plural pairs)
# Contain all the entries for all the languages defined in the dictionaries.
# The default plural for languages not explicitly listed here
# is the :zero_one_other plural (used for English)
Hash.new(plurals[:zero_one_other]).tap do |languages|
languages['en'] = plurals[:zero_one_other]
languages['ru'] = plurals[:zero_one_few_many_other]
languages['pl'] = plurals[:pl]
# Hash of locale/pluralization pairs
# Contain all the entries for all the locales defined as dictionaries.
# The default pluralization for locales not explicitly listed here
# is the :zero_one_other pluralization proc (used for English)
Hash.new(p11n[:zero_one_other]).tap do |hash|
hash['ru'] = p11n[:zero_one_few_many_other]
hash['pl'] = p11n[:pl]
end

# PR for other languages and plurals are very welcome. Thanks!
# PR for other locales and pluralizations are very welcome. Thanks!
10 changes: 6 additions & 4 deletions lib/pagy/extras/i18n.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
# See the Pagy documentation: https://ddnexus.github.io/pagy/extras/i18n
# frozen_string_literal: true

class Pagy
# Use ::I18n gem
module Frontend

::I18n.load_path += Dir[Pagy.root.join('locales', '*.yml')]

# Override the built-in pagy_t
def pagy_t(*args)
::I18n.t(*args)
end
Pagy::I18n.clear.instance_eval { undef :load; undef :t } # unload the pagy default constant for efficiency

# no :pagy_without_i18n alias with the i18n gem
def pagy_t_with_i18n(*args) ::I18n.t(*args) end
alias :pagy_t :pagy_t_with_i18n

end
end
Loading

0 comments on commit 9c1212b

Please sign in to comment.