From 895a681224a5552cd45991de48027c55c285a57b Mon Sep 17 00:00:00 2001 From: Steffen Beyer Date: Thu, 23 Jan 2025 13:38:43 +0100 Subject: [PATCH] improvement: gettext_backend convenience wrapper (#563) Enhances the router macros by a `gettext_backend` option, leading to the creation of the required `gettext_fn` on the fly. --- documentation/tutorials/ui-overrides.md | 18 +++---- lib/ash_authentication_phoenix/router.ex | 65 ++++++++++++++++++++++-- lib/ash_authentication_phoenix_web.ex | 23 ++++----- mix.exs | 1 + mix.lock | 2 + test/password_reset_test.exs | 10 +++- test/sign_in_test.exs | 10 +++- test/support/gettext.ex | 16 ++++++ test/support/phoenix.ex | 15 +++++- test/test_helper.exs | 6 --- 10 files changed, 126 insertions(+), 40 deletions(-) create mode 100644 test/support/gettext.ex diff --git a/documentation/tutorials/ui-overrides.md b/documentation/tutorials/ui-overrides.md index 73ff0278..7f73d5b1 100644 --- a/documentation/tutorials/ui-overrides.md +++ b/documentation/tutorials/ui-overrides.md @@ -28,28 +28,22 @@ You only need to define the overrides you want to change. Unspecified overrides ## Internationalisation -Plug in your own i18n library by providing a gettext-like function, for example in your Gettext backend module: +Plug in your Gettext backend and have all display text translated automagically, see next section for an example. -```elixir -defmodule MyAppWeb.Gettext do - use Gettext.Backend, otp_app: :my_app - - def translate_auth(msgid, bindings \\ []), do: Gettext.dgettext(__MODULE__, "auth", msgid, bindings) -end -``` - -The repository includes Gettext templates for the untranslated messages and a growing number of translations. You might want to +The package includes Gettext templates for the untranslated messages and a growing number of translations. You might want to ```sh cp -rv deps/ash_authentication_phoenix/i18n/gettext/* priv/gettext ``` +For other i18n libraries you have the option to provide a gettext-like handler function, see `AshAuthentication.Phoenix.Router.sign_in_route/1` for details. + ## Telling AshAuthentication about your overrides To do this, you modify your `sign_in_route` calls to contain the `overrides` option. Be sure to put the `AshAuthentication.Phoenix.Overrides.Default` override last, as it contains the default values for all components! -The same way you may add a `gettext_fn` option to specify your translation function as a `{module, function}` tuple. +The same way you may add a `gettext_backend` option to specify your Gettext backend and domain. ```elixir defmodule MyAppWeb.Router do @@ -60,7 +54,7 @@ defmodule MyAppWeb.Router do scope "/", MyAppWeb do sign_in_route overrides: [MyAppWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.Default], - gettext_fn: {MyAppWeb.Gettext, :translate_auth} + gettext_backend: {MyAppWeb.Gettext, "auth"} end end ``` diff --git a/lib/ash_authentication_phoenix/router.ex b/lib/ash_authentication_phoenix/router.ex index f462b6e5..d8f39817 100644 --- a/lib/ash_authentication_phoenix/router.ex +++ b/lib/ash_authentication_phoenix/router.ex @@ -202,8 +202,11 @@ defmodule AshAuthentication.Phoenix.Router do * `otp_app` the otp app or apps to find authentication resources in. Pulls from the socket by default. * `overrides` specify any override modules for customisation. See `AshAuthentication.Phoenix.Overrides` for more information. - * `gettext_fn` as a `{module, function}` tuple pointing to a `(msgid :: String.t(), bindings :: Keyword.t()) :: String.t()` - typed function that will be called to translate each output text of the live view. + * `gettext_fn` as a `{module :: module, function :: atom}` tuple pointing to a + `(msgid :: String.t(), bindings :: keyword) :: String.t()` typed function that will be called to translate + each output text of the live view. + * `gettext_backend` as a `{module :: module, domain :: String.t()}` tuple pointing to a Gettext backend module + and specifying the Gettext domain. This is basically a convenience wrapper around `gettext_fn`. All other options are passed to the generated `scope`. """ @@ -212,9 +215,10 @@ defmodule AshAuthentication.Phoenix.Router do {:path, String.t()} | {:live_view, module} | {:as, atom} - | {:overrides, [module]} | {:on_mount, [module]} + | {:overrides, [module]} | {:gettext_fn, {module, atom}} + | {:gettext_backend, {module, String.t()}} | {atom, any} ] ) :: Macro.t() @@ -229,10 +233,14 @@ defmodule AshAuthentication.Phoenix.Router do {register_path, opts} = Keyword.pop(opts, :register_path) {auth_routes_prefix, opts} = Keyword.pop(opts, :auth_routes_prefix) {gettext_fn, opts} = Keyword.pop(opts, :gettext_fn) + {gettext_backend, opts} = Keyword.pop(opts, :gettext_backend) {overrides, opts} = Keyword.pop(opts, :overrides, [AshAuthentication.Phoenix.Overrides.Default]) + gettext_fn = + maybe_generate_gettext_fn_pointer(gettext_fn, gettext_backend, __CALLER__.module, path) + opts = opts |> Keyword.put_new(:alias, false) @@ -312,6 +320,8 @@ defmodule AshAuthentication.Phoenix.Router do end end end + + unquote(generate_gettext_fn(gettext_backend, path)) end end @@ -348,8 +358,11 @@ defmodule AshAuthentication.Phoenix.Router do * `as` which is passed to the generated `live` route. Defaults to `:auth`. * `overrides` specify any override modules for customisation. See `AshAuthentication.Phoenix.Overrides` for more information. - * `gettext_fn` as a `{module, function}` tuple pointing to a `(msgid :: String.t(), bindings :: Keyword.t()) :: String.t()` - typed function that will be called to translate each output text of the live view. + * `gettext_fn` as a `{module :: module, function :: atom}` tuple pointing to a + `(msgid :: String.t(), bindings :: keyword) :: String.t()` typed function that will be called to translate + each output text of the live view. + * `gettext_backend` as a `{module :: module, domain :: String.t()}` tuple pointing to a Gettext backend module + and specifying the Gettext domain. This is basically a convenience wrapper around `gettext_fn`. All other options are passed to the generated `scope`. """ @@ -360,6 +373,7 @@ defmodule AshAuthentication.Phoenix.Router do | {:as, atom} | {:overrides, [module]} | {:gettext_fn, {module, atom}} + | {:gettext_backend, {module, String.t()}} | {:on_mount, [module]} | {atom, any} ] @@ -373,10 +387,14 @@ defmodule AshAuthentication.Phoenix.Router do {on_mount, opts} = Keyword.pop(opts, :on_mount) {auth_routes_prefix, opts} = Keyword.pop(opts, :auth_routes_prefix) {gettext_fn, opts} = Keyword.pop(opts, :gettext_fn) + {gettext_backend, opts} = Keyword.pop(opts, :gettext_backend) {overrides, opts} = Keyword.pop(opts, :overrides, [AshAuthentication.Phoenix.Overrides.Default]) + gettext_fn = + maybe_generate_gettext_fn_pointer(gettext_fn, gettext_backend, __CALLER__.module, path) + opts = opts |> Keyword.put_new(:alias, false) @@ -429,6 +447,8 @@ defmodule AshAuthentication.Phoenix.Router do live("/:token", unquote(live_view), :reset, as: unquote(as)) end end + + unquote(generate_gettext_fn(gettext_backend, path)) end end @@ -438,4 +458,39 @@ defmodule AshAuthentication.Phoenix.Router do |> Map.put("tenant", Ash.PlugHelpers.get_tenant(conn)) |> Map.put("context", Ash.PlugHelpers.get_context(conn)) end + + # When using a `gettext_backend`, we generate a function in the caller's router module, involving the + # path as unique id for the function name + defp generate_gettext_fn(nil, _id), do: "" + + defp generate_gettext_fn({module, domain}, id) do + if Code.ensure_loaded?(Gettext) do + quote do + # sobelow_skip ["DOS.BinToAtom"] - based on auth route + def unquote(:"__translate#{id}")(msgid, bindings) do + Gettext.dgettext(unquote(module), unquote(domain), msgid, bindings) + end + end + else + raise "gettext_backend: Gettext is not available" + end + end + + defp maybe_generate_gettext_fn_pointer(nil, nil, _router_module, _id), do: nil + + # Prefer gettext_fn over gettext_backend + defp maybe_generate_gettext_fn_pointer(gettext_fn, _ignore, _router_module, _id) + when is_tuple(gettext_fn), + do: gettext_fn + + # Point to the "translate" function generated by `generate_gettext_fn` + defp maybe_generate_gettext_fn_pointer(_gettext_fn, {_module, _domain}, router_module, id), + # sobelow_skip ["DOS.BinToAtom"] - based on auth route + do: {router_module, :"__translate#{id}"} + + defp maybe_generate_gettext_fn_pointer(_gettext_fn, invalid, _router_module, _id) do + raise ArgumentError, + "gettext_backend: #{inspect(invalid)} is invalid - specify " <> + "`{module :: module, domain :: String.t()}`" + end end diff --git a/lib/ash_authentication_phoenix_web.ex b/lib/ash_authentication_phoenix_web.ex index 38921f06..a27ff5a3 100644 --- a/lib/ash_authentication_phoenix_web.ex +++ b/lib/ash_authentication_phoenix_web.ex @@ -65,26 +65,22 @@ defmodule AshAuthentication.Phoenix.Web do end quote generated: true do - case unquote(msgid) do - nil -> - "" - - msg -> - Web.gettext_switch( - unquote(gettext_fn), - msg, - unquote(bindings) - ) - end + Web.gettext_switch(unquote(gettext_fn), unquote(msgid), unquote(bindings)) end end end end - @spec gettext_switch({module, atom} | nil, String.t(), keyword) :: String.t() + @spec gettext_switch( + gettext_fn :: {module, atom} | nil, + msgid :: String.t(), + bindings :: keyword + ) :: String.t() @doc """ If a translation function is provided, we call that, otherwise return the input untranslated. """ + def gettext_switch(_gettext_fn, nil, _bindings), do: "" + def gettext_switch({module, function}, msgid, bindings) when is_atom(module) and is_atom(function) do apply(module, function, [msgid, bindings]) @@ -97,7 +93,8 @@ defmodule AshAuthentication.Phoenix.Web do end def gettext_switch(invalid, _msgid, _bindings) do - raise "gettext_fn: #{inspect(invalid)} is invalid - specify `{module, function}` " <> + raise ArgumentError, + "gettext_fn: #{inspect(invalid)} is invalid - specify `{module, function}` " <> "for a function with a `gettext/2` like signature" end diff --git a/mix.exs b/mix.exs index 7fb4ed2c..ac3a0670 100644 --- a/mix.exs +++ b/mix.exs @@ -127,6 +127,7 @@ defmodule AshAuthentication.Phoenix.MixProject do {:phoenix, "~> 1.6"}, {:bcrypt_elixir, "~> 3.0"}, {:slugify, "~> 1.3"}, + {:gettext, "~> 0.26", optional: true}, {:igniter, "~> 0.5 and >= 0.5.1", optional: true}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index 86146c14..aa4606f8 100644 --- a/mix.lock +++ b/mix.lock @@ -21,10 +21,12 @@ "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, "ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"}, "ex_doc": {:git, "https://github.com/elixir-lang/ex_doc.git", "6151194c42ab94d24abf3db8cebd69421532d262", []}, + "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, + "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, "git_ops": {:hex, :git_ops, "2.6.3", "38c6e381b8281b86e2911fa39bea4eab2d171c86d7428786566891efb73b68c3", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a81cb6c6a2a026a4d48cb9a2e1dfca203f9283a3a70aa0c7bc171970c44f23f8"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, diff --git a/test/password_reset_test.exs b/test/password_reset_test.exs index 89e72951..cf4a0e30 100644 --- a/test/password_reset_test.exs +++ b/test/password_reset_test.exs @@ -19,10 +19,18 @@ defmodule AshAuthentication.Phoenix.PasswordResetTest do end test "reset_route routes liveview honours external gettext_fn", %{conn: conn} do - # Translator mock at AshAuthentication.Phoenix.Test.Helper.gettext + # Translator stub at AshAuthentication.Phoenix.Test.Gettext.translate_auth conn = get(conn, "/vergessen/meine_wertmarke_213") assert {:ok, _view, html} = live(conn) refute html =~ "Password reset" assert html =~ "ever gonna" end + + test "reset_route routes liveview honours external gettext_backend", %{conn: conn} do + # Translator stub at AshAuthentication.Phoenix.Test.Gettext + conn = get(conn, "/vergessen_backend/meine_wertmarke_213") + assert {:ok, _view, html} = live(conn) + refute html =~ "Password reset" + assert html =~ "ever gonna" + end end diff --git a/test/sign_in_test.exs b/test/sign_in_test.exs index bffedd1f..6bb8dfbf 100644 --- a/test/sign_in_test.exs +++ b/test/sign_in_test.exs @@ -73,10 +73,18 @@ defmodule AshAuthentication.Phoenix.SignInTest do end test "sign_in routes liveview honours external gettext_fn", %{conn: conn} do - # Translator mock at AshAuthentication.Phoenix.Test.Helper.gettext + # Translator stub at AshAuthentication.Phoenix.Test.Gettext.translate_auth conn = get(conn, "/anmeldung") assert {:ok, _view, html} = live(conn) refute html =~ "Sign in" assert html =~ "ever gonna" end + + test "sign_in routes liveview honours external gettext_backend", %{conn: conn} do + # Translator stub at AshAuthentication.Phoenix.Test.Gettext + conn = get(conn, "/anmeldung_backend") + assert {:ok, _view, html} = live(conn) + refute html =~ "Sign in" + assert html =~ "ever gonna" + end end diff --git a/test/support/gettext.ex b/test/support/gettext.ex new file mode 100644 index 00000000..7ea9dac4 --- /dev/null +++ b/test/support/gettext.ex @@ -0,0 +1,16 @@ +defmodule AshAuthentication.Phoenix.Test.Gettext do + @moduledoc """ + Gettext stub, + referenced in AshAuthentication.Phoenix.Test.Router + """ + use Gettext.Backend, otp_app: :ash_authentication_phoenix + + @spec translate_test(String.t(), keyword) :: String.t() + def translate_test(_msgid, _bindings) do + "Never gonna give you up!" + end + + @impl true + def handle_missing_translation(_locale, _domain, _msgctxt, _msgid, _bindings), + do: {:ok, translate_test("_", [])} +end diff --git a/test/support/phoenix.ex b/test/support/phoenix.ex index f1b04e26..6997c5a9 100644 --- a/test/support/phoenix.ex +++ b/test/support/phoenix.ex @@ -80,15 +80,26 @@ defmodule AshAuthentication.Phoenix.Test.Router do reset_route auth_routes_prefix: "/auth" auth_routes AuthController, Example.Accounts.User, path: "/auth" + # Gettext routes sign_in_route path: "/anmeldung", auth_routes_prefix: "/auth", - gettext_fn: {AshAuthentication.Phoenix.Test.Helper, :gettext}, + gettext_fn: {AshAuthentication.Phoenix.Test.Gettext, :translate_test}, as: :gettext reset_route path: "/vergessen", auth_routes_prefix: "/auth", - gettext_fn: {AshAuthentication.Phoenix.Test.Helper, :gettext}, + gettext_fn: {AshAuthentication.Phoenix.Test.Gettext, :translate_test}, as: :gettext + + sign_in_route path: "/anmeldung_backend", + auth_routes_prefix: "/auth", + gettext_backend: {AshAuthentication.Phoenix.Test.Gettext, "test"}, + as: :gettext_backend + + reset_route path: "/vergessen_backend", + auth_routes_prefix: "/auth", + gettext_backend: {AshAuthentication.Phoenix.Test.Gettext, "test"}, + as: :gettext_backend end scope "/nested", AshAuthentication.Phoenix.Test do diff --git a/test/test_helper.exs b/test/test_helper.exs index d45c55ad..e84a7cae 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,9 +1,3 @@ ExUnit.start() AshAuthentication.Phoenix.Test.Endpoint.start_link() - -defmodule AshAuthentication.Phoenix.Test.Helper do - def gettext(_msgid, _bindings) do - "Never gonna give you up!" - end -end