How to prefix VerifiedRoutes with locale?

I’m attempting to create localized routes like so:

@supported_locales ["en-us", "en-gb"]

scope "/", AppWeb do
  pipe_through :browser

  for supported_locale <- @supported_locales do
    get "/#{supported_locale}", PageController, :home
    get "/#{supported_locale}/features", PageController, :features
    get "/#{supported_locale}/pricing", PageController, :pricing
  end
end

All is good! But there’s a problem! I got the following error when I tried to use ~p.

no route path for AppWeb.Router matches "/#{@page_locale}/pricing"

My question is, how do I prefix ~p?

Thank you.

You‘d need to wrap it at compile time like you created the routes at compile time. Once your routes are in the router they don‘t have a variable path prefix. There just remains a bunch if hardcoded paths, which need to be supplied as hardcoded paths for validation as well.

What is the value of the module var @page_locale ?

in ex_cldr_routes I introduced a sigil_q to do localised verified routes. In the end, it generates a case expression that, as @LostKobrakai says, detects the locale at runtime and uses that to decide which verified route to use.

You can see the implementation here, perhaps it will help define your own solution.

The docs have a description of how that all works together.

2 Likes

If you don’t mind, can you give me some hints on how to wrap ~p at compile time?

Those are from heex assigns.

Ah ok, it’s sometimes confusing, I thought you were using it in a controller.

I suggest start with the doc link I posted above, and then look at the implementation of sigil_q, Happy to answer any questions here, on slack, or DM me.

1 Like

Hey @kip, thank you so much for all your CLDR packages. I use them in almost all my projects.

I actually looked into ex_cldr_routes before posting this. I was wondering if there might be an easier way to do this.

Because of the way your routes are defined the locale segment of the paths are not dynamic, so you need to use hardcoded values:

~p"/en-us/features"

If you want the locale segment of the route paths to be dynamic you need to change how your routes are defined:

get ":locale/features", PageController, :features

After some readings on macros (this is my first time writing macros) and the implementation of ex_cldr_routes ~q, here’s a working solution that I got:

defmodule VerifiedLocalizedRoutes do
  @moduledoc false

  defmacro __using__(opts) do
    quote location: :keep do
      unquote(__MODULE__).__using__(__MODULE__, unquote(opts))
      import unquote(__MODULE__), only: :macros
    end
  end

  def __using__(mod, opts) do
    Module.put_attribute(mod, :locales, Keyword.fetch!(opts, :locales))
    Module.put_attribute(mod, :get_locale, Keyword.fetch!(opts, :get_locale))
  end

  defmacro sigil_q({:<<>>, _meta, _segments} = route, flags) do
    locales = attr!(__CALLER__, :locales)
    get_locale = attr!(__CALLER__, :get_locale)

    case_clauses =
      sigil_q_case_clauses(route, flags, locales)

    quote location: :keep do
      case unquote(get_locale).() do
        unquote(case_clauses)
      end
    end
  end

  defp interpolate_path(path, locale) do
    Macro.prewalk(path, fn
      segment when is_binary(segment) ->
        String.replace(segment, ":locale", locale)

      other ->
        other
    end)
  end

  defp attr!(env, name) do
    Module.get_attribute(env.module, name) || raise "expected @#{name} module attribute to be set"
  end

  defp sigil_q_case_clauses(route, flags, locales) do
    for locale <- locales do
      route_path = interpolate_path(route, locale)

      quote location: :keep do
        unquote(locale) -> sigil_p(unquote(route_path), unquote(flags))
      end
    end
    |> Enum.reject(&is_nil/1)
    |> Enum.map(&hd/1)
  end
end

Usage Example:

use VerifiedLocalizedRoute,
    locales: ["en-us", "en-gb"],
    get_locale: &get_locale/0
2 Likes

Just a small matter of hygiene - you are strongly encouraged to use your own top-level module names, not that of another lib. ie replace Phoenix with a top level name of your own.

1 Like

Ok, that’s a good practice. I updated the code. Thanks so much.

@sammkj, thanks for sharing.

Just in case somebody is looking for a full example on how to use above module (assuming app named “Hello”):

# lib/hello_web.ex
def verified_routes do
  quote do
    use Phoenix.VerifiedRoutes,
      endpoint: HelloWeb.Endpoint,
      router: HelloWeb.Router,
      statics: HelloWeb.static_paths()

    # Add this after Phoenix.VerifiedRoutes
    use HelloWeb.VerifiedLocalizedRoutes,
        locales: ["en", "ru"],
        get_locale: &Gettext.get_locale/0
  end
end

# lib/hello_web/router.ex
scope "/:locale" do
  pipe_through :browser
  get "/pages", PageController, :index
end

# *.heex
<.link navigate={~q"/:locale/pages"}>Pages</.link>

# resulting HTML:
<a href="/en/pages">Pages</a>