diff --git a/lib/phoenix_kit/utils/routes.ex b/lib/phoenix_kit/utils/routes.ex index b9d4266e..16caa67c 100644 --- a/lib/phoenix_kit/utils/routes.ex +++ b/lib/phoenix_kit/utils/routes.ex @@ -37,8 +37,10 @@ defmodule PhoenixKit.Utils.Routes do base_path = if url_prefix === "/", do: "", else: url_prefix cond do - # Admin paths ALWAYS get locale prefix to stay within the - # :phoenix_kit_admin_locale live_session and avoid full-page reloads. + # Admin paths follow the same primary-prefixless rule as non-admin + # paths (see `build_admin_path/3`). The dual-scope router emission + # keeps `/phoenix_kit/admin/*` AND `/phoenix_kit/:locale/admin/*` + # both reachable, so the emitted shape is purely cosmetic. admin_path?(url_path) -> locale = resolve_locale(opts) build_admin_path(base_path, url_path, locale) @@ -85,13 +87,26 @@ defmodule PhoenixKit.Utils.Routes do # Check if a path is an admin path. defp admin_path?(url_path), do: String.starts_with?(url_path, "/admin") - # Admin paths ALWAYS include locale (even default locale) to match the - # :phoenix_kit_admin_locale live_session scope (/:locale/admin/*). - # This prevents live_session boundary crossings that cause full-page reloads. + # Admin paths drop the locale segment for the primary language, matching + # the non-admin behaviour in `build_localized_path/3`. The two emission + # shapes both have routes in the table (the admin route macros declare + # both `/:locale/admin/*` and `/admin/*` scopes), so emitting prefixless + # for the primary locale is safe. + # + # Known tradeoff: switching locales inside admin via the UI today crosses + # the `:phoenix_kit_admin_locale` ↔ `:phoenix_kit_admin` live_session + # boundary and forces a full-page reload. Unifying those sessions so + # locale-switching stays inside the WebSocket is tracked separately — + # see `dev_docs/primary_language_no_prefix_plan.md` (TODO 1). defp build_admin_path(base_path, url_path, :none), do: "#{base_path}#{url_path}" - defp build_admin_path(base_path, url_path, locale) when is_binary(locale), - do: "#{base_path}/#{locale}#{url_path}" + defp build_admin_path(base_path, url_path, locale) when is_binary(locale) do + if default_locale?(locale) do + "#{base_path}#{url_path}" + else + "#{base_path}/#{locale}#{url_path}" + end + end defp build_admin_path(base_path, url_path, _), do: "#{base_path}#{url_path}" @@ -143,17 +158,25 @@ defmodule PhoenixKit.Utils.Routes do end @doc """ - Returns a locale-prefixed admin path, bypassing the reserved-path - locale stripping that `path/2` applies. + Returns a locale-aware admin path. Strips the locale segment for the + primary language (mirroring `path/2`'s non-admin behaviour); keeps the + segment for every other locale. - Admin routes use a `/:locale/admin/*` scope, so they need locale - in the URL even though `/admin` is a reserved prefix. + Both URL shapes resolve at the router level — the admin route macros + declare `/:locale/admin/*` AND `/admin/*` scopes — so emitting prefixless + for primary is safe. Locale switching across the two shapes today still + crosses a `live_session` boundary and reloads; see + `dev_docs/primary_language_no_prefix_plan.md` (TODO 1) for the unification + work. ## Examples iex> Routes.admin_path("/admin/users", "uk") "/phoenix_kit/uk/admin/users" + iex> Routes.admin_path("/admin/users", "en") + "/phoenix_kit/admin/users" + iex> Routes.admin_path("/admin/users", nil) "/phoenix_kit/admin/users" @@ -161,7 +184,12 @@ defmodule PhoenixKit.Utils.Routes do def admin_path(url_path, locale) when is_binary(locale) do url_prefix = Config.get_url_prefix() base_prefix = if url_prefix == "/", do: "", else: url_prefix - "#{base_prefix}/#{locale}#{url_path}" + + if default_locale?(locale) do + "#{base_prefix}#{url_path}" + else + "#{base_prefix}/#{locale}#{url_path}" + end end def admin_path(url_path, _locale), do: path(url_path) diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index ada2aad9..1aad6dff 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -84,6 +84,11 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do attr :from_layout, :boolean, default: false attr :pk_pending_invitations, :list, default: [] + attr :module_assigns, :map, + default: %{}, + doc: + "Module-supplied host-consumable assigns. Each key in this map is merged into the assigns set passed to the parent layout (`Layouts.app`), so a host's custom layout can read e.g. `assigns[:phoenix_kit_publishing_translations]` from publishing, or any other module-defined key. Plain `conn.assigns` don't reach a function-component layout — only declared attrs do — so this single map attribute is how modules thread arbitrary host-consumable data through the boundary without core having to declare each one explicitly." + slot :inner_block, required: false def app_layout(assigns) do @@ -748,6 +753,18 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do # Prepare assigns for parent layout compatibility defp prepare_parent_layout_assigns(assigns) do + # Flatten `:module_assigns` into the top-level assigns map FIRST so that + # host layouts can read module-supplied keys directly (e.g. + # `assigns[:phoenix_kit_publishing_translations]`). Existing top-level + # keys win over module-supplied ones to prevent a module from + # overwriting core-managed assigns like `:flash` or `:current_user`. + module_assigns = assigns[:module_assigns] || %{} + + assigns = + Enum.reduce(module_assigns, assigns, fn {key, value}, acc -> + Map.put_new(acc, key, value) + end) + assigns |> Map.put_new(:current_user, get_current_user_for_parent(assigns)) |> Map.put_new(:phoenix_kit_integrated, true) diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index d845f701..30994cf2 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -1293,10 +1293,15 @@ defmodule PhoenixKitWeb.Integration do # Override Phoenix.Router's call/2 (defoverridable from `use Phoenix.Router`). # Path-rewrites publishing-bound URLs before super() runs the matcher. + # The workspace's url_prefix is threaded in so the dispatch keeps + # working when PhoenixKit is mounted under a non-root path (e.g. + # `/phoenix_kit`) — without this, the dispatch's prepended segments + # land at the head of path_info instead of after the prefix, and + # nothing matches the registered internal routes. # See PhoenixKitPublishing.RouterDispatch for the rationale. def call(conn, opts) do conn = - case PhoenixKitPublishing.RouterDispatch.maybe_rewrite(conn) do + case PhoenixKitPublishing.RouterDispatch.maybe_rewrite(conn, unquote(url_prefix)) do {:rewrite, rewritten} -> rewritten :pass -> conn end diff --git a/lib/phoenix_kit_web/users/auth.ex b/lib/phoenix_kit_web/users/auth.ex index 0e6b83dc..8095af50 100644 --- a/lib/phoenix_kit_web/users/auth.ex +++ b/lib/phoenix_kit_web/users/auth.ex @@ -133,20 +133,16 @@ defmodule PhoenixKitWeb.Users.Auth do end # This function renews the session ID and erases the whole - # session to avoid fixation attacks. See renew_session/1 below - # which preserves locale preference across session renewal. + # session to avoid fixation attacks. Locale is no longer kept in + # session (URL is authoritative; logged-in users persist preference + # on `user.custom_fields["preferred_locale"]`), so there's nothing + # to preserve across renewal. defp renew_session(conn) do - # Preserve locale preference across session renewal - locale_base = get_session(conn, :phoenix_kit_locale_base) - delete_csrf_token() conn |> configure_session(renew: true) |> clear_session() - |> then(fn conn -> - if locale_base, do: put_session(conn, :phoenix_kit_locale_base, locale_base), else: conn - end) end @doc """ @@ -713,8 +709,13 @@ defmodule PhoenixKitWeb.Users.Auth do current_base = socket.assigns[:current_locale_base] if current_base != locale and DialectMapper.valid_base_code?(locale) do - user = socket.assigns[:phoenix_kit_current_user] - full_dialect = DialectMapper.resolve_dialect(locale, user) + # URL-driven dialect: don't pass the user — `DialectMapper.resolve_dialect/2` + # would otherwise upgrade base "en" → user.preferred_locale "en-GB", + # which contradicts the URL-is-authoritative semantic we now hold + # across both the LV mount and the HTTP plug. `preferred_locale` is + # still written by the switcher hook but no longer read for routing + # (base or dialect). + full_dialect = DialectMapper.resolve_dialect(locale) # Update Gettext locale — set both the backend-specific value (for # PhoenixKitWeb.Gettext callers that look it up explicitly) and the @@ -732,41 +733,32 @@ defmodule PhoenixKitWeb.Users.Auth do end end - # No locale in params - could be: - # 1. Default language URL (clean URL without prefix) - should use default locale - # 2. Reserved path (admin, api, etc.) - should preserve user's session preference + # No locale in URL = primary language. Snap Gettext + assigns to default. + # + # Pre-fix this branch special-cased reserved paths (`/admin`, `/api`, …) + # to "preserve session locale" — back when admin URLs always carried a + # locale prefix, the reserved-path snap was unreachable. Now that + # `Routes.admin_path/2` emits prefixless URLs for the primary language, + # the reserved-path branch was active and pinned a stale session locale + # to every prefixless admin navigation (sticky-Estonian bug). Removing + # the special case restores the URL-is-authoritative semantics. defp maybe_update_locale_from_params(socket, _params) do - url_path = socket.assigns[:url_path] || "" - - # Check if we're on a reserved path (admin, api, etc.) - # These paths never have locale prefix, so we should preserve user's preference - reserved_prefixes = ~w(/admin /api /webhooks /assets /static /files /images) - is_reserved_path = Enum.any?(reserved_prefixes, &String.contains?(url_path, &1)) + default_base = Routes.get_default_admin_locale() + current_base = socket.assigns[:current_locale_base] - if is_reserved_path do - # Preserve existing locale from session - don't reset to default - # The locale was already set correctly in mount_phoenix_kit_current_scope + if current_base == default_base do socket else - # Normal frontend path without locale prefix = default language URL - default_base = Routes.get_default_admin_locale() - current_base = socket.assigns[:current_locale_base] - - # If we're already on the default locale, no need to update - if current_base == default_base do - socket - else - # URL has no locale prefix, so we're navigating to default language - user = socket.assigns[:phoenix_kit_current_user] - default_dialect = DialectMapper.resolve_dialect(default_base, user) + # URL-driven dialect (see sibling clause above for the rationale — + # `preferred_locale` is intentionally ignored for routing). + default_dialect = DialectMapper.resolve_dialect(default_base) - Gettext.put_locale(PhoenixKitWeb.Gettext, default_dialect) - Gettext.put_locale(default_dialect) + Gettext.put_locale(PhoenixKitWeb.Gettext, default_dialect) + Gettext.put_locale(default_dialect) - socket - |> Phoenix.Component.assign(:current_locale_base, default_base) - |> Phoenix.Component.assign(:current_locale, default_dialect) - end + socket + |> Phoenix.Component.assign(:current_locale_base, default_base) + |> Phoenix.Component.assign(:current_locale, default_dialect) end end @@ -801,10 +793,15 @@ defmodule PhoenixKitWeb.Users.Auth do user = socket.assigns.phoenix_kit_current_user scope = Scope.for_user(user) - # Get locale from params (URL path) first, then session, then defaults - # This ensures locale from URL takes precedence during initial mount - session_locale = session["phoenix_kit_locale_base"] - + # Locale is URL-driven: the URL's `:locale` segment wins; absent + # that, we fall straight to the default. The previous session-locale + # fallback ("remember the last-picked locale across prefixless URLs") + # caused a sticky-locale bug after `Routes.path/2` was changed to + # emit prefixless URLs for the primary language — e.g. visiting + # `/foo/et/...` once stashed `"et"` in the session, then every + # primary-prefixless URL inherited Estonian forever. Pure URL→default + # makes prefixless URL ≡ primary language, which matches what the + # URL helpers now emit. current_locale_base = case params do %{"locale" => locale} when is_binary(locale) and locale != "" -> @@ -813,11 +810,13 @@ defmodule PhoenixKitWeb.Users.Auth do _ -> nil end || - session_locale || - Process.get(:phoenix_kit_current_locale_base) || Routes.get_default_admin_locale() - current_locale = DialectMapper.resolve_dialect(current_locale_base, user) + # URL-driven dialect: pass no user so `resolve_dialect/2` returns + # the default mapping for this base. The user's preferred_locale + # is intentionally NOT consulted for routing (see + # `maybe_update_locale_from_params/2` for the matching rationale). + current_locale = DialectMapper.resolve_dialect(current_locale_base) # Set Gettext locale for translations (backend-specific + global, so # module backends like PhoenixKitProjects.Gettext sync too). @@ -1749,30 +1748,28 @@ defmodule PhoenixKitWeb.Users.Auth do end _ -> - # No locale in URL - check user's preferred locale first, then fall back to default - # This supports admin paths where locale can't be in URL but user has a preference - current_user = get_user_for_locale_resolution(conn) - - {base, dialect} = - case get_user_preferred_locale(current_user) do - {preferred_base, preferred_dialect} when is_binary(preferred_base) -> - # User has a valid preferred locale - use it - {preferred_base, preferred_dialect} - - _ -> - # No user preference - use default language - default_base = Routes.get_default_admin_locale() - default_dialect = DialectMapper.resolve_dialect(default_base, current_user) - {default_base, default_dialect} - end + # No locale in URL → primary language. Pure URL → default, matching + # the LV mount path (`mount_phoenix_kit_current_scope/3`). Previously + # this branch consulted `user.custom_fields["preferred_locale"]` + # before the default, but the LV mount doesn't read it — a + # logged-in user with preferred_locale=de visiting `/admin/foo` + # would see German for the initial HTTP response and then English + # after the LV mount snapped to default. The two paths now agree: + # URL is the only source of truth. The preferred_locale field is + # still written by the switcher (so the data is preserved for any + # future feature that wants to re-enable it) but never read for + # routing — and that includes dialect resolution: `resolve_dialect/2` + # gets called without the user so user-preferred dialect upgrades + # (e.g. base "en" → "en-GB" via custom_fields) don't sneak back in. + default_base = Routes.get_default_admin_locale() + default_dialect = DialectMapper.resolve_dialect(default_base) - Gettext.put_locale(PhoenixKitWeb.Gettext, dialect) - Gettext.put_locale(dialect) + Gettext.put_locale(PhoenixKitWeb.Gettext, default_dialect) + Gettext.put_locale(default_dialect) conn - |> assign(:current_locale_base, base) - |> assign(:current_locale, dialect) - |> put_session(:phoenix_kit_locale_base, base) + |> assign(:current_locale_base, default_base) + |> assign(:current_locale, default_dialect) end end @@ -1793,10 +1790,10 @@ defmodule PhoenixKitWeb.Users.Auth do path -> path end) - # Set locale before redirecting + # Set locale before redirecting. URL-driven dialect — no user (see + # `process_valid_locale/2` for the rationale). default_base = Routes.get_default_admin_locale() - current_user = get_user_for_locale_resolution(conn) - default_dialect = DialectMapper.resolve_dialect(default_base, current_user) + default_dialect = DialectMapper.resolve_dialect(default_base) Gettext.put_locale(PhoenixKitWeb.Gettext, default_dialect) Gettext.put_locale(default_dialect) @@ -1807,40 +1804,6 @@ defmodule PhoenixKitWeb.Users.Auth do |> halt() end - # Helper to get user for locale resolution - # Checks conn assigns first, then tries to fetch from session token if available - defp get_user_for_locale_resolution(conn) do - case conn.assigns[:phoenix_kit_current_user] do - nil -> - # User not assigned yet, try to fetch from session token - case get_session(conn, "user_token") do - nil -> nil - token -> Auth.get_user_by_session_token(token) - end - - user -> - user - end - end - - # Get user's preferred locale if set and valid - # Returns {base_code, full_dialect} tuple or nil if not set/invalid - defp get_user_preferred_locale(nil), do: nil - - defp get_user_preferred_locale(%{custom_fields: %{"preferred_locale" => preferred}}) - when is_binary(preferred) and preferred != "" do - base = DialectMapper.extract_base(preferred) - - # Verify the preferred locale is a valid enabled language - if DialectMapper.valid_base_code?(base) and language_enabled?(base) do - {base, preferred} - else - nil - end - end - - defp get_user_preferred_locale(_user), do: nil - defp locale_allowed?(base_code) do language_enabled?(base_code) end @@ -1864,14 +1827,21 @@ defmodule PhoenixKitWeb.Users.Auth do # Process a validated and enabled locale. # For non-admin paths: redirect default locale to clean URL (no prefix needed). - # Admin paths ALWAYS keep the locale in the URL to stay within the - # :phoenix_kit_admin_locale live_session and avoid full-page reloads. + # Admin paths: do NOT redirect — both `/phoenix_kit/admin/*` and + # `/phoenix_kit//admin/*` are valid (the dual-scope router + # emission accepts both), and `Routes.admin_path/2` emits the + # prefixless shape by default. We honor whichever URL shape the user + # typed; canonicalizing to one would force a redirect that crosses + # the still-split `:phoenix_kit_admin_locale` ↔ `:phoenix_kit_admin` + # live_session boundary (see TODO 1 in + # `dev_docs/primary_language_no_prefix_plan.md`). defp process_valid_locale(conn, locale) do if locale == Routes.get_default_admin_locale() and not admin_request?(conn) do redirect_default_locale_to_clean_url(conn, locale) else - current_user = get_user_for_locale_resolution(conn) - full_dialect = DialectMapper.resolve_dialect(locale, current_user) + # URL-driven dialect — no user (see `process_as_default_locale/1` + # for the rationale matching the LV mount path). + full_dialect = DialectMapper.resolve_dialect(locale) Gettext.put_locale(PhoenixKitWeb.Gettext, full_dialect) Gettext.put_locale(full_dialect) @@ -1879,13 +1849,13 @@ defmodule PhoenixKitWeb.Users.Auth do conn |> assign(:current_locale_base, locale) |> assign(:current_locale, full_dialect) - |> put_session(:phoenix_kit_locale_base, locale) end end - # Check if the request path is an admin path. - # Admin paths must keep the locale in the URL to stay within the - # :phoenix_kit_admin_locale live_session boundary. + # Check if the request path is an admin path. Used by + # `process_valid_locale/2` to skip the default-locale-redirect so + # both `/phoenix_kit/admin/*` and `/phoenix_kit//admin/*` + # render whichever shape the user typed. defp admin_request?(conn) do String.contains?(conn.request_path, "/admin") end diff --git a/priv/static/assets/phoenix_kit.js b/priv/static/assets/phoenix_kit.js index 2f5f5af3..03a22833 100644 --- a/priv/static/assets/phoenix_kit.js +++ b/priv/static/assets/phoenix_kit.js @@ -2172,6 +2172,16 @@ if (typeof window.Chart === "undefined") { this.trigger = this.el.querySelector("[data-row-menu-trigger]"); this.menu = this.el.querySelector("[data-row-menu-content]"); this.isOpen = false; + // Track where the menu element originally lives so we can restore + // it on close. We portal it to while open so `position: fixed` + // coords escape any containing block created by a `` in the + // top layer or any ancestor with `transform`/`contain`/`filter` (all + // of which establish a new fixed-positioning origin). Without the + // portal, `getBoundingClientRect()` returns viewport coords but the + // browser applies them relative to the nearest containing block, + // shoving the menu hundreds of pixels off-screen inside modals. + this._homeParent = this.menu.parentNode; + this._homeNextSibling = this.menu.nextSibling; this._onTriggerClick = (e) => { e.stopPropagation(); @@ -2208,6 +2218,16 @@ if (typeof window.Chart === "undefined") { }, _open() { + // Portal to before measuring. If the menu sits inside a + // or any ancestor that establishes a fixed-positioning + // containing block, `position: fixed` coords would be interpreted + // relative to that ancestor instead of the viewport. Moving the + // menu to makes `getBoundingClientRect()` and the resulting + // `left`/`top` values consistent. + if (this.menu.parentNode !== document.body) { + document.body.appendChild(this.menu); + } + var triggerRect = this.trigger.getBoundingClientRect(); var vw = window.innerWidth; var vh = window.innerHeight; @@ -2251,6 +2271,19 @@ if (typeof window.Chart === "undefined") { this.trigger.setAttribute("aria-expanded", "false"); document.removeEventListener("click", this._onOutsideClick, true); document.removeEventListener("keydown", this._onKeydown); + + // Restore the menu to its original location so LiveView's diff + // patching can find it on subsequent updates. Without this the + // menu would stay parented to and LV's morphdom pass would + // see a "missing" element inside the row and re-create it, + // doubling the DOM and breaking the next open. + if (this._homeParent && this.menu.parentNode !== this._homeParent) { + if (this._homeNextSibling && this._homeNextSibling.parentNode === this._homeParent) { + this._homeParent.insertBefore(this.menu, this._homeNextSibling); + } else { + this._homeParent.appendChild(this.menu); + } + } }, destroyed() { diff --git a/test/phoenix_kit/utils/routes_test.exs b/test/phoenix_kit/utils/routes_test.exs new file mode 100644 index 00000000..1228b0e3 --- /dev/null +++ b/test/phoenix_kit/utils/routes_test.exs @@ -0,0 +1,78 @@ +defmodule PhoenixKit.Utils.RoutesTest do + use ExUnit.Case + + alias PhoenixKit.Utils.Routes + + # All assertions below assume the default language resolves to "en". + # `Routes.get_default_language_base/0` rescues any DB lookup failure and + # falls back to "en", so these tests run without a connected database. + # If the test repo is ever wired up, the default still resolves to "en" + # unless a different language is explicitly configured as default. + + describe "admin_path/2 — primary-language prefix stripping" do + test "primary locale (en) emits no prefix" do + assert Routes.admin_path("/admin/users", "en") == "/phoenix_kit/admin/users" + end + + test "non-primary locale keeps its prefix" do + assert Routes.admin_path("/admin/users", "de") == "/phoenix_kit/de/admin/users" + assert Routes.admin_path("/admin/users", "fr") == "/phoenix_kit/fr/admin/users" + end + + test "nil locale falls back to no prefix (no locale segment)" do + # When no locale is supplied, admin_path delegates to `path/1`. With a + # primary-locale fallback that's "en", the result is unprefixed. + assert Routes.admin_path("/admin/users", nil) == "/phoenix_kit/admin/users" + end + + test "nested admin paths follow the same rule" do + assert Routes.admin_path("/admin/users/edit/123", "en") == + "/phoenix_kit/admin/users/edit/123" + + assert Routes.admin_path("/admin/users/edit/123", "de") == + "/phoenix_kit/de/admin/users/edit/123" + end + end + + describe "path/2 — non-admin routes (regression coverage)" do + test "primary locale emits no prefix (unchanged behaviour)" do + assert Routes.path("/users/log-in", locale: "en") == "/phoenix_kit/users/log-in" + end + + test "non-primary locale keeps its prefix" do + assert Routes.path("/users/log-in", locale: "de") == "/phoenix_kit/de/users/log-in" + end + + test ":none locale opt skips the prefix entirely" do + assert Routes.path("/users/log-in", locale: :none) == "/phoenix_kit/users/log-in" + end + end + + describe "admin_path/2 — backcompat for legacy /en/admin URLs" do + # The prefixed shape is no longer EMITTED for the primary language, + # but it must still RESOLVE (the dual-scope admin route emission + # declares both `/:locale/admin/*` and `/admin/*`). Anyone with a + # bookmark or external link pointing at `/phoenix_kit/en/admin/...` + # should still reach the page. This test pins the helper-side half + # of that contract: explicitly passing the primary locale produces + # the prefixless shape (intended), while a non-primary locale still + # produces the prefixed shape (which legacy `/en/admin/...` URLs + # also match against in the route table). + test "explicit primary locale → prefixless (intended emission)" do + assert Routes.admin_path("/admin/users", "en") == "/phoenix_kit/admin/users" + end + + test "explicit non-primary locale → prefixed (also the shape a legacy /en/ URL takes)" do + assert Routes.admin_path("/admin/users", "de") == "/phoenix_kit/de/admin/users" + end + + test "dialect-shaped locale code keeps its prefix verbatim" do + # Full dialect codes (e.g. en-US) shouldn't ever reach the helper — + # callers normalise to base via DialectMapper first — but if they + # do, the helper passes them through unchanged. Pinning this so a + # future "smart-strip" refactor doesn't accidentally treat + # "en-US" the same as "en" and drop the prefix. + assert Routes.admin_path("/admin/users", "en-US") == "/phoenix_kit/en-US/admin/users" + end + end +end