Skip to content

Commit a1e6ebf

Browse files
committed
Add path_for/2 and url_for/2 to Localize.VerifiedRoutes
1 parent 5b3dfca commit a1e6ebf

5 files changed

Lines changed: 228 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
44

5+
## [0.6.0] - 2026-05-11
6+
7+
### Enhancements
8+
9+
* Add `path_for/2` and `url_for/2` macros to `Localize.VerifiedRoutes` to render a verified path or URL in an explicit locale without changing the process-wide locale, supporting language-switcher and hreflang use cases that need every configured locale rendered in one template pass.
10+
511
## [0.5.1] - 2026-04-25
612

713
### Bug Fixes

guides/phoenix-localized-routing.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,41 @@ The `~q` sigil supports the same locale interpolations as the `localize` macro:
257257
# Produces "/fr/pages_fr/intro" when the locale is :fr
258258
```
259259

260+
### Rendering a Path or URL in a Specific Locale
261+
262+
`~q` dispatches on the *current* process locale (`Localize.get_locale/0`). When you need to render a link in a different locale at the call site — without changing the process locale — use `path_for/2` and `url_for/2`. Typical use cases are language switchers and emitting hreflang links per locale in one template pass.
263+
264+
```elixir
265+
<.link navigate={path_for(:fr, "/users")}>Français</.link>
266+
<.link navigate={path_for(:de, "/users")}>Deutsch</.link>
267+
```
268+
269+
The locale argument can also be a runtime expression, which is the more common form when the locale list is iterated:
270+
271+
```elixir
272+
<%= for locale <- [:en, :fr, :de] do %>
273+
<.link navigate={path_for(locale, "/users")}><%= locale %></.link>
274+
<% end %>
275+
```
276+
277+
`url_for/2` returns a full URL via `Phoenix.VerifiedRoutes.url/1`:
278+
279+
```elixir
280+
url_for(:fr, "/users")
281+
#=> "http://localhost:4000/users_fr"
282+
```
283+
284+
The route argument accepts the same form as `~q` — a string literal with optional `#{...}` interpolations and `:locale` / `:language` / `:territory` substitutions.
285+
286+
For ad-hoc blocks where you need a whole region of code to run under a specific locale (perhaps because you also want locale-sensitive number or date formatting), `Localize.with_locale/2` temporarily switches the locale and restores it afterwards:
287+
288+
```elixir
289+
Localize.with_locale(:fr, fn -> ~q"/users" end)
290+
#=> "/users_fr"
291+
```
292+
293+
Use `path_for/2` for single-call locale forcing; use `Localize.with_locale/2` for whole-block temporary locale changes.
294+
260295
## Inspecting Localized Routes
261296

262297
Localized routes are stored in a `LocalizedRoutes` submodule. You can inspect them with the `phx.routes` mix task:

lib/localize/routes/verified_routes.ex

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,25 @@ defmodule Localize.VerifiedRoutes do
2828
2929
* `:territory` is replaced with the CLDR territory code.
3030
31+
### Rendering a path or URL in a specific locale
32+
33+
`sigil_q` dispatches on the *current* process locale set by
34+
`Localize.put_locale/1`. When you need to render a link in a different
35+
locale without changing the process locale — for example, emitting a
36+
language switcher that lists the same page in every configured locale —
37+
use `path_for/2` and `url_for/2`:
38+
39+
# In a template, with @locale bound from the request or session:
40+
<.link href={path_for(@locale, "/users")}>Users</.link>
41+
42+
# Render every configured locale in one pass (language switcher):
43+
for locale <- [:en, :fr, :de] do
44+
path_for(locale, "/users")
45+
end
46+
47+
url_for(:fr, "/users")
48+
#=> "http://localhost/users_fr"
49+
3150
"""
3251

3352
defmacro __using__(opts) do
@@ -123,6 +142,96 @@ defmodule Localize.VerifiedRoutes do
123142
end
124143
end
125144

145+
@doc ~S'''
146+
Generates a localized verified path in a specific locale.
147+
148+
Unlike `sigil_q/2`, which dispatches on the *current* locale
149+
(`Localize.get_locale/0`), `path_for/2` lets the caller force a particular
150+
locale at the call site without changing the process-wide locale. This is
151+
useful when rendering links in multiple locales within a single template
152+
(for example, a language switcher).
153+
154+
### Arguments
155+
156+
* `locale` is any locale id configured in the gettext backend. May be a
157+
literal atom or a runtime expression.
158+
159+
* `route` is a string literal route (with optional `#{...}` interpolations),
160+
as accepted by `sigil_q/2`.
161+
162+
### Examples
163+
164+
path_for(:fr, "/users")
165+
#=> "/utilisateurs"
166+
167+
for locale <- [:en, :fr] do
168+
{locale, path_for(locale, "/users")}
169+
end
170+
#=> [en: "/users", fr: "/utilisateurs"]
171+
172+
'''
173+
defmacro path_for(locale, route) do
174+
gettext = Module.get_attribute(__CALLER__.module, :_localize_gettext_backend)
175+
locale_ids = Localize.Routes.locales_from_gettext(gettext)
176+
route_ast = Localize.VerifiedRoutes.normalize_route_ast(route)
177+
178+
case_clauses =
179+
Localize.VerifiedRoutes.sigil_q_case_clauses(route_ast, [], locale_ids, gettext)
180+
181+
quote location: :keep do
182+
case unquote(locale) do
183+
unquote(case_clauses)
184+
end
185+
end
186+
end
187+
188+
@doc ~S'''
189+
Generates a localized verified URL in a specific locale.
190+
191+
Like `path_for/2` but returns a full URL via `Phoenix.VerifiedRoutes.url/1`.
192+
193+
### Arguments
194+
195+
* `locale` is any locale id configured in the gettext backend.
196+
197+
* `route` is a string literal route accepted by `sigil_q/2`.
198+
199+
'''
200+
defmacro url_for(locale, route) do
201+
gettext = Module.get_attribute(__CALLER__.module, :_localize_gettext_backend)
202+
locale_ids = Localize.Routes.locales_from_gettext(gettext)
203+
route_ast = Localize.VerifiedRoutes.normalize_route_ast(route)
204+
205+
case_clauses =
206+
Localize.VerifiedRoutes.sigil_q_case_clauses(route_ast, [], locale_ids, gettext)
207+
208+
case_expr =
209+
quote location: :keep do
210+
case unquote(locale) do
211+
unquote(case_clauses)
212+
end
213+
end
214+
215+
wrap_sigil_p_in_url(case_expr)
216+
end
217+
218+
@doc false
219+
# Normalises the second arg of `path_for/2`/`url_for/2`. Accepts either a
220+
# plain string literal (passed straight through the macro as a binary) or
221+
# an interpolated-string AST (`{:<<>>, _, _}`) and returns the AST shape
222+
# expected by `sigil_q_case_clauses/4`.
223+
def normalize_route_ast(route) when is_binary(route) do
224+
{:<<>>, [], [route]}
225+
end
226+
227+
def normalize_route_ast({:<<>>, _, _} = ast), do: ast
228+
229+
def normalize_route_ast(other) do
230+
raise ArgumentError,
231+
"path_for/2 and url_for/2 expect a string literal route " <>
232+
"(optionally with \#{...} interpolations); got: #{Macro.to_string(other)}"
233+
end
234+
126235
@doc false
127236
def sigil_q_case_clauses(route, flags, locale_ids, gettext_backend) do
128237
for locale_id <- locale_ids do

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule LocalizeWeb.MixProject do
22
use Mix.Project
33

4-
@version "0.5.1"
4+
@version "0.6.0"
55

66
def project do
77
[

test/path_for_test.exs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
defmodule PathFor.Test do
2+
use ExUnit.Case
3+
4+
use Localize.VerifiedRoutes,
5+
router: MyApp.Router,
6+
endpoint: MyApp.Endpoint,
7+
gettext: MyApp.Gettext
8+
9+
# `path_for/2` and `url_for/2` let the caller force a specific locale at the
10+
# call site, independent of the process-wide locale set by
11+
# `Localize.put_locale/1`. Mirrors the macros added to ex_cldr_routes for
12+
# https://github.com/elixir-cldr/cldr_routes/issues/18.
13+
14+
describe "path_for/2" do
15+
test "renders the literal locale's translation regardless of current locale" do
16+
Localize.put_locale(:en)
17+
assert path_for(:fr, "/users") == "/users_fr"
18+
assert path_for(:de, "/users") == "/users_de"
19+
assert path_for(:en, "/users") == "/users"
20+
end
21+
22+
test "does not mutate the process locale" do
23+
Localize.put_locale(:en)
24+
_ = path_for(:fr, "/users")
25+
assert Localize.get_locale().cldr_locale_id == :en
26+
end
27+
28+
test "supports a runtime locale expression" do
29+
Localize.put_locale(:en)
30+
31+
for locale <- [:en, :fr, :de] do
32+
expected =
33+
case locale do
34+
:en -> "/users"
35+
:fr -> "/users_fr"
36+
:de -> "/users_de"
37+
end
38+
39+
assert path_for(locale, "/users") == expected
40+
end
41+
end
42+
43+
test "supports interpolation in the route string" do
44+
user_id = 42
45+
assert path_for(:fr, "/users/#{user_id}") == "/users_fr/42"
46+
end
47+
48+
test "supports :locale / :language / :territory interpolation" do
49+
assert path_for(:de, "/users/:locale") == "/users_de/de"
50+
assert path_for(:fr, "/users/:territory") == "/users_fr/fr"
51+
end
52+
53+
test "renders multiple locales in one template-style pass" do
54+
Localize.put_locale(:en)
55+
56+
pairs =
57+
for locale <- [:en, :fr, :de] do
58+
{locale, path_for(locale, "/users")}
59+
end
60+
61+
assert pairs == [{:en, "/users"}, {:fr, "/users_fr"}, {:de, "/users_de"}]
62+
end
63+
end
64+
65+
describe "url_for/2" do
66+
test "renders a full URL in the specified locale" do
67+
Localize.put_locale(:en)
68+
assert url_for(:fr, "/users") == "http://localhost/users_fr"
69+
assert url_for(:de, "/users") == "http://localhost/users_de"
70+
assert url_for(:en, "/users") == "http://localhost/users"
71+
end
72+
73+
test "supports interpolations" do
74+
assert url_for(:de, "/users/:locale") == "http://localhost/users_de/de"
75+
end
76+
end
77+
end

0 commit comments

Comments
 (0)