PhoenixKitPublishing.RouterDispatch (PhoenixKitPublishing v0.1.9)

Copy Markdown View Source

Routing strategy that lets publishing's catch-all coexist with host routes shaped /:locale/something/... declared after phoenix_kit_routes().

Why this exists

Publishing's public URLs are dynamic — /:language/:group/*path where :group is any DB row. Phoenix.Router has no per-segment regex constraint, so the catch-all matches every two-or-more-segment URL. Phoenix matches in declaration order with no fall-through, which means any host route declared after phoenix_kit_routes() and shaped /:locale/<literal>/... is silently shadowed: publishing's controller matches first, doesn't find the group, returns 404.

How this works

Instead of registering the catch-all directly under /, publishing registers it under an internal prefix (/__phoenix_kit_publishing_dispatch). The host router's call/2 is overridden (via Phoenix.Router's documented defoverridable init: 1, call: 2) to:

  1. Check if the request's first non-locale path segment is a known publishing group via maybe_rewrite/1.
  2. If yes — rewrite conn.path_info and conn.request_path to prepend the internal prefix; super(conn, opts) then matches the internal-prefix route and runs the full Phoenix pipeline normally.
  3. If no — pass through unchanged; Phoenix matches host routes.

After the route matches, restore_path/2 (a pipeline plug) un-mutates request_path and path_info so controllers reading conn.request_path for canonical URL generation see the URL the client sent, not the internal prefix.

Phoenix's pipelines run via the internal-prefix scope's pipe_through, so :browser (sessions, CSRF, layout), :phoenix_kit_* (auth, locale), and any host-defined plugs are applied via the standard mechanism — no manual replication, no telemetry gap.

Trade-offs

  • mix phx.routes shows the routes under the internal prefix. Devs debugging "where does /blog/post go?" need to know to look for __phoenix_kit_publishing_dispatch. Documented in core AGENTS.md.
  • Per-request cost: one DB lookup on each request. ETS/:persistent_term caching is a future optimization (the DB read is small + indexed).

Summary

Functions

Internal prefix segment used in path rewriting.

Discriminator segment for URLs that have a leading locale (i.e. the group slug is at path_info[1]). Phoenix routes inside this sub-scope bind :language + :group per publishing's localized form.

Decide whether conn is bound for publishing.

Same as maybe_rewrite/1 but explicitly takes the workspace's url_prefix so the dispatch keeps working when the host mounts PhoenixKit under a non-root path (e.g. /phoenix_kit).

Pipeline plug — restores conn.request_path and conn.path_info to the un-rewritten form once Phoenix has extracted route bindings into conn.params.

Discriminator segment for URLs that have NO leading locale (i.e. the group slug is at path_info[0]). Phoenix routes inside this sub-scope bind :group only per publishing's non-localized form.

Functions

internal_prefix()

@spec internal_prefix() :: String.t()

Internal prefix segment used in path rewriting.

localized_segment()

@spec localized_segment() :: String.t()

Discriminator segment for URLs that have a leading locale (i.e. the group slug is at path_info[1]). Phoenix routes inside this sub-scope bind :language + :group per publishing's localized form.

maybe_rewrite(conn)

@spec maybe_rewrite(Plug.Conn.t()) :: {:rewrite, Plug.Conn.t()} | :pass

Decide whether conn is bound for publishing.

Returns {:rewrite, conn} with conn.path_info and conn.request_path prepended with the internal prefix + a discriminator segment that picks publishing's localized vs non-localized route shape:

  • If path_info[1] is a known group → rewrite under __phoenix_kit_publishing_dispatch/localized/... so Phoenix matches /:language/:group(/*path) (the URL has a leading locale).
  • Else if path_info[0] is a known group → rewrite under __phoenix_kit_publishing_dispatch/root/... so Phoenix matches /:group(/*path) (no leading locale).

Returns :pass if neither resolves to a known group. The dual discriminator is load-bearing — without it, both /:language/:group and /:group/*path would match a 2-segment internal path, and Phoenix's first-match-wins picks the localized form even when the URL had no locale prefix. That sends the request to the controller with language=<group-slug>, group=<post-slug> and the lookup fails.

The DB lookup is small and indexed; a future optimization would cache the slug set in :persistent_term.

maybe_rewrite(conn, url_prefix)

@spec maybe_rewrite(Plug.Conn.t(), String.t()) :: {:rewrite, Plug.Conn.t()} | :pass

Same as maybe_rewrite/1 but explicitly takes the workspace's url_prefix so the dispatch keeps working when the host mounts PhoenixKit under a non-root path (e.g. /phoenix_kit).

When url_prefix == "/" this behaves identically to the unscoped form. Otherwise the prefix's path segments are stripped from path_info before the group-candidate check, and re-prepended to the rewritten path so it matches the internal scope's registered routes (also nested under the prefix).

restore_path(conn, opts)

@spec restore_path(
  Plug.Conn.t(),
  keyword()
) :: Plug.Conn.t()

Pipeline plug — restores conn.request_path and conn.path_info to the un-rewritten form once Phoenix has extracted route bindings into conn.params.

Without this, controllers that compute canonical URLs from conn.request_path (publishing's default_language_no_prefix redirect is one) include the internal prefix in their Location header, causing a redirect loop on the next request.

root_segment()

@spec root_segment() :: String.t()

Discriminator segment for URLs that have NO leading locale (i.e. the group slug is at path_info[0]). Phoenix routes inside this sub-scope bind :group only per publishing's non-localized form.