diff --git a/config/config.exs b/config/config.exs index b9b77ee..4c60237 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,3 +1,5 @@ use Mix.Config config :linguist, pluralization_key: :count +config :ex_cldr, + locales: ["fr", "en", "es"] diff --git a/lib/linguist/compiler.ex b/lib/linguist/compiler.ex index ff69dd5..5954c14 100644 --- a/lib/linguist/compiler.ex +++ b/lib/linguist/compiler.ex @@ -34,6 +34,9 @@ defmodule Linguist.Compiler do (?) /x + def interpol_rgx do + @interpol_rgx + end @escaped_interpol_rgx ~r/%%{/ @simple_interpol "%{" diff --git a/lib/linguist/memorized_vocabulary.ex b/lib/linguist/memorized_vocabulary.ex new file mode 100644 index 0000000..bd25cb2 --- /dev/null +++ b/lib/linguist/memorized_vocabulary.ex @@ -0,0 +1,173 @@ +defmodule Linguist.MemorizedVocabulary do + alias Linguist.Compiler + alias Linguist.NoTranslationError + alias Cldr.Number.Cardinal + + defmodule TranslationDecodeError do + defexception [:message] + end + + @moduledoc """ + Defines lookup functions for given translation locales, binding interopolation + + Locales are defined with the `locale/2` function, accepting a locale name and + a String path to evaluate for the translations list. + + For example, given the following translations : + + locale "en", [ + flash: [ + notice: [ + hello: "hello %{first} %{last}", + ] + ], + users: [ + title: "Users", + ] + ] + + locale "fr", Path.join([__DIR__, "fr.exs"]) + + this module will respond to these functions : + + t("en", "flash.notice.hello", bindings \\ []), do: # ... + t("en", "users.title", bindings \\ []), do: # ... + t("fr", "flash.notice.hello", bindings \\ []), do: # ... + """ + + def t(locale, path, bindings \\ []) do + pluralization_key = Application.fetch_env!(:linguist, :pluralization_key) + if Keyword.has_key?(bindings, pluralization_key) do + plural_atom = + Cardinal.plural_rule( + Keyword.get(bindings, pluralization_key), + locale + ) + + do_t(locale, "#{path}.#{plural_atom}", bindings) + else + do_t(locale, path, bindings) + end + end + + def t!(locale, path, bindings \\ []) do + case t(locale, path, bindings) do + {:ok, translation} -> translation + {:error, :no_translation} -> + raise %NoTranslationError{message: "#{locale}: #{path}"} + end + end + + defp do_t(locale, translation_key, bindings) do + case :ets.lookup(:translations_registry, "#{locale}.#{translation_key}") do + [] -> {:error, :no_translation} + [{_, string}] -> + translation = + Compiler.interpol_rgx() + |> Regex.split(string, on: [:head, :tail]) + |> Enum.reduce("", fn + <<"%{" <> rest>>, acc -> + key = String.to_atom(String.trim_trailing(rest, "}")) + + acc <> to_string(Keyword.fetch!(bindings, key)) + segment, acc -> + acc <> segment + end) + {:ok, translation} + end + end + + def locales do + tuple = :ets.lookup(:translations_registry, "memorized_vocabulary.locales") + |> List.first() + if tuple do + elem(tuple, 1) + end + end + + def add_locale(name) do + current_locales = locales() || [] + :ets.insert(:translations_registry, {"memorized_vocabulary.locales", [name | current_locales]}) + end + + def update_translations(locale_name, loaded_source) do + loaded_source + |> Enum.map(fn({key, translation_string}) -> + :ets.insert(:translations_registry, {"#{locale_name}.#{key}", translation_string}) + end) + end + + @doc """ + Embeds locales from provided source + + * name - The String name of the locale, ie "en", "fr" + * source - The String file path to load YAML from that returns a structured list of translations + + Examples + + locale "es", Path.join([__DIR__, "es.yml"]) + """ + def locale(name, source) do + loaded_source = Linguist.MemorizedVocabulary._load_yaml_file(source) + update_translations(name, loaded_source) + add_locale(name) + end + + @doc """ + Function used internally to load a yaml file. Please use + the `locale` macro with a path to a yaml file - this function + will not work as expected if called directly. + """ + def _load_yaml_file(source) do + if :ets.info(:translations_registry) == :undefined do + :ets.new(:translations_registry, [:named_table, :set, :protected]) + end + + {decode_status, [file_data]} = Yomel.decode_file(source) + if decode_status != :ok do + raise %TranslationDecodeError{message: "Decode failed for file #{source}"} + end + + %{paths: paths} = file_data + |> Enum.reduce(%{paths: %{}, current_prefix: ""}, &Linguist.MemorizedVocabulary._yaml_reducer/2) + paths + end + + @doc """ + Recursive function used internally for loading yaml files. + Not intended for external use + """ + def _yaml_reducer({key, value}, acc) when is_binary(value) do + key_name = if acc.current_prefix == "" do + key + else + "#{acc.current_prefix}.#{key}" + end + + %{ + paths: Map.put(acc.paths, key_name, value), + current_prefix: acc.current_prefix + } + end + def _yaml_reducer({key, value}, acc) do + next_prefix = if acc.current_prefix == "" do + key + else + "#{acc.current_prefix}.#{key}" + end + + reduced = Enum.reduce( + value, + %{ + paths: acc.paths, + current_prefix: next_prefix + }, + &Linguist.MemorizedVocabulary._yaml_reducer/2 + ) + + %{ + paths: Map.merge(acc.paths, reduced.paths), + current_prefix: acc.current_prefix + } + end +end diff --git a/lib/linguist/vocabulary.ex b/lib/linguist/vocabulary.ex index 8bdbae7..c1af2a6 100644 --- a/lib/linguist/vocabulary.ex +++ b/lib/linguist/vocabulary.ex @@ -87,7 +87,8 @@ defmodule Linguist.Vocabulary do will not work as expected if called directly. """ def _load_yaml_file(source) do - YamlElixir.read_from_file!(source) + {:ok, [result]} = Yomel.decode_file(source) + result |> Enum.reduce([], &Linguist.Vocabulary._yaml_reducer/2) end diff --git a/mix.exs b/mix.exs index 5accd56..3db179f 100644 --- a/mix.exs +++ b/mix.exs @@ -7,6 +7,7 @@ defmodule Linguist.Mixfile do [ app: :linguist, version: "0.1.5", + compilers: Mix.compilers ++ [:cldr], elixir: "~> 1.6", deps: deps(), package: [ @@ -21,14 +22,15 @@ defmodule Linguist.Mixfile do end def application do - [applications: [:yaml_elixir]] + [applications: []] end defp deps do [ {:ex_cldr, "~> 1.5"}, {:jason, "~> 1.0"}, - {:yaml_elixir, "~> 2.0"} + {:yomel, "~> 0.5"}, + {:credo, "~> 0.9.0", only: [:dev, :test], runtime: false} ] end end diff --git a/mix.lock b/mix.lock index de9fe70..6cc81ef 100644 --- a/mix.lock +++ b/mix.lock @@ -1,8 +1,12 @@ %{ "abnf2": {:hex, :abnf2, "0.1.2", "6f8792b8ac3288dba5fc889c2bceae9fe78f74e1a7b36bea9726ffaa9d7bef95", [:mix], [], "hexpm"}, + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, + "credo": {:hex, :credo, "0.9.2", "841d316612f568beb22ba310d816353dddf31c2d94aa488ae5a27bb53760d0bf", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, "decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], [], "hexpm"}, "ex_cldr": {:hex, :ex_cldr, "1.5.2", "5c8fe295fef680a821b9e0c19242ea34037af11eb59e6d98f194e6c9c3b4252e", [:mix], [{:abnf2, "~> 0.1", [hex: :abnf2, repo: "hexpm", optional: false]}, {:decimal, "~> 1.4", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.13", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, "~> 2.1 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm"}, "jason": {:hex, :jason, "1.0.0", "0f7cfa9bdb23fed721ec05419bcee2b2c21a77e926bce0deda029b5adc716fe2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, + "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, "yamerl": {:hex, :yamerl, "0.7.0", "e51dba652dce74c20a88294130b48051ebbbb0be7d76f22de064f0f3ccf0aaf5", [:rebar3], [], "hexpm"}, "yaml_elixir": {:hex, :yaml_elixir, "2.0.0", "5d7c40e039b076c0da1921b2754d4a91bc435ac4434bef633f5506dbafd6b8f2", [:mix], [{:yamerl, "~> 0.5", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm"}, + "yomel": {:hex, :yomel, "0.5.0", "c5a42d1818deda3f85ae14b1f01f6ece22b9ed8e8087012359fc04b59d85f621", [:make, :mix], [], "hexpm"}, } diff --git a/test/memorized_vocabulary_test.exs b/test/memorized_vocabulary_test.exs new file mode 100644 index 0000000..f2842b5 --- /dev/null +++ b/test/memorized_vocabulary_test.exs @@ -0,0 +1,34 @@ +defmodule MemorizedVocabularyTest do + use ExUnit.Case + + setup do + Linguist.MemorizedVocabulary.locale("es", Path.join([__DIR__, "es.yml"])) + :ok + end + + test "locales() returns locales" do + assert ["es"] == Linguist.MemorizedVocabulary.locales() + end + + test "t returns a translation" do + assert {:ok, "bar"} == Linguist.MemorizedVocabulary.t("es", "foo") + end + + test "t interpolates values" do + assert {:ok, "hola Michael Westin"} == Linguist.MemorizedVocabulary.t("es", "flash.notice.hello", first: "Michael", last: "Westin") + end + + test "t returns {:error, :no_translation} when translation is missing" do + assert Linguist.MemorizedVocabulary.t("es", "flash.not_exists") == {:error, :no_translation} + end + + test "t! raises NoTranslationError when translation is missing" do + assert_raise Linguist.NoTranslationError, fn -> + Linguist.MemorizedVocabulary.t!("es", "flash.not_exists") + end + end + + test "t pluralizes" do + assert {:ok, "2 manzanas"} == Linguist.MemorizedVocabulary.t("es", "apple", count: 2) + end +end diff --git a/test/vocabulary_test.exs b/test/vocabulary_test.exs index 4a1288a..46ba186 100644 --- a/test/vocabulary_test.exs +++ b/test/vocabulary_test.exs @@ -1,4 +1,4 @@ -defmodule LinguistTest do +defmodule VocabularyTest do use ExUnit.Case defmodule I18n do @@ -99,6 +99,11 @@ defmodule LinguistTest do assert I18n.t!("en", "apple", count: 2) == "2 apples" end + test "pluralizes Spanish correctly" do + assert I18n.t!("es", "apple", count: 1) == "1 manzana" + assert I18n.t!("es", "apple", count: 2) == "2 manzanas" + end + test "throws an error when a pluralized string is not given a count" do assert_raise Linguist.NoTranslationError, fn -> I18n.t!("en", "apple")