Skip to content

Commit

Permalink
improvement: gettext_backend convenience wrapper (#563)
Browse files Browse the repository at this point in the history
Enhances the router macros by a `gettext_backend` option, leading to the
creation of the required `gettext_fn` on the fly.
  • Loading branch information
serpent213 authored Jan 23, 2025
1 parent dbe3926 commit 895a681
Show file tree
Hide file tree
Showing 10 changed files with 126 additions and 40 deletions.
18 changes: 6 additions & 12 deletions documentation/tutorials/ui-overrides.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```
Expand Down
65 changes: 60 additions & 5 deletions lib/ash_authentication_phoenix/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
"""
Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -312,6 +320,8 @@ defmodule AshAuthentication.Phoenix.Router do
end
end
end

unquote(generate_gettext_fn(gettext_backend, path))
end
end

Expand Down Expand Up @@ -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`.
"""
Expand All @@ -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}
]
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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
23 changes: 10 additions & 13 deletions lib/ash_authentication_phoenix_web.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
2 changes: 2 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
10 changes: 9 additions & 1 deletion test/password_reset_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 9 additions & 1 deletion test/sign_in_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions test/support/gettext.ex
Original file line number Diff line number Diff line change
@@ -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
15 changes: 13 additions & 2 deletions test/support/phoenix.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 0 additions & 6 deletions test/test_helper.exs
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 895a681

Please sign in to comment.