Skip to content
52 changes: 40 additions & 12 deletions lib/phoenix_kit/utils/routes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}"

Expand Down Expand Up @@ -143,25 +158,38 @@ 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"

"""
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)
Expand Down
17 changes: 17 additions & 0 deletions lib/phoenix_kit_web/components/layout_wrapper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion lib/phoenix_kit_web/integration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading