Skip to content

URL-driven locale resolution + admin-URL prefixless + module_assigns generalization#551

Merged
ddon merged 9 commits into
BeamLabEU:devfrom
mdon:dev
May 19, 2026
Merged

URL-driven locale resolution + admin-URL prefixless + module_assigns generalization#551
ddon merged 9 commits into
BeamLabEU:devfrom
mdon:dev

Conversation

@mdon
Copy link
Copy Markdown
Contributor

@mdon mdon commented May 19, 2026

Summary

Locale routing, admin URL shapes, and the LayoutWrapper attr contract — three intertwined fixes that have to land together so the matching publishing PR can rely on the new core API.

What's in this PR

1. Admin URLs render prefixless for the primary language (5b9853ff)

  • Routes.admin_path/2 and the internal build_admin_path/3 now strip the locale segment when it matches the default — mirrors what build_localized_path/3 already does for non-admin routes.
  • Both URL shapes still resolve: /phoenix_kit/admin/users AND /phoenix_kit/en/admin/users work; the dual-scope admin route emission keeps the legacy prefixed shape addressable.
  • The live_session unification (so locale switches in admin don't reload) is deferred and documented in dev_docs/primary_language_no_prefix_plan.md (TODO 1).
  • New test/phoenix_kit/utils/routes_test.exs pins both helper shapes with backcompat coverage.

2. URL is the only source of truth for locale routing (fecf3d3e, 85f11427, c836286e)

  • Dropped the session-locale fallback in both the LV mount path (mount_phoenix_kit_current_scope/3) and the HTTP plug. The previous fallback chain (URL → session → default) caused a sticky-locale trap: visiting /foo/et/... once stashed "et" in the session, then every prefixless URL inherited Estonian — refreshing didn't help.
  • preferred_locale is no longer read for routing (base OR dialect). The field is still WRITTEN by the language-switcher hook so future features can re-enable it; it just doesn't influence routing today.
  • Deleted get_user_for_locale_resolution/1 (dead after the changes) and get_user_preferred_locale/1.
  • renew_session/1 no longer preserves the dead session key across login/logout.
  • All six DialectMapper.resolve_dialect/2 callsites in phoenix_kit_web/users/auth.ex switched to the arity-1 variant so dialect resolution doesn't sneak preferred_locale back in either.

3. LayoutWrapper.app_layout generalized via :module_assigns (b17b96b7)

  • Replaces the publishing-specific :phoenix_kit_publishing_translations attr with a generic :module_assigns map. Each key in the map is merged into the top-level assigns (with Map.put_new — core keys win) before the parent Layouts.app is called.
  • Future modules thread their host-consumable keys through without core having to declare each one. Same merge mechanism the publishing PR uses for both :phoenix_kit_publishing_translations and :og.

Verification

  • mix test test/phoenix_kit/utils/routes_test.exs — 10/0
  • mix test test/phoenix_kit/languages/dialect_mapper_test.exs — 24/0
  • Chrome live verification with the matching publishing PR: /phoenix_kit/et/users/log-in renders Estonian, /phoenix_kit/users/log-in renders English; no flash, no sticky locale, plug and LV agree.

Coordination

This PR pairs with phoenix_kit_publishing #TBD which uses the new :module_assigns shape. Either lands first as long as both land — they're functionally additive.

Test plan

  • CI green (compile, format, credo, dialyzer, tests)
  • Visit /phoenix_kit/admin/... and /phoenix_kit/<locale>/admin/... — both resolve
  • Switch locale via in-page switcher; URL bar updates correctly
  • Visit /foo/et/... then a prefixless URL — locale flips back to default (no sticky session)

mdon added 9 commits May 19, 2026 08:15
The publishing dispatch override prepends its internal-prefix +
discriminator segments to `conn.path_info` before letting Phoenix
re-match. When the workspace mounts at a non-root path (the
default `/phoenix_kit`), the prepended segments landed at the head
of path_info while the registered internal routes are scoped UNDER
the workspace prefix — so the rewritten path didn't match anything
and every public publishing URL returned 404.

Pass `url_prefix` through to `maybe_rewrite/2` so it can strip the
prefix from path_info before the group-candidate check (otherwise
`phoenix_kit` was mistaken for a language code) and re-prepend it
to the rewritten path so it matches the registered routes.
`<.app_layout>` is a function component, so it only sees explicitly-
declared attrs — conn assigns that aren't passed in are invisible to
both the wrapper and the host's `Layouts.app` it forwards to.

Publishing exposes a per-translation URL list as the conn assign
`:phoenix_kit_publishing_translations` so host apps can build their
own language switcher in their root or app layout with correct
per-language URLs (important for posts with language-specific
slugs that simple locale-prefix-rewrite gets wrong). The conn assign
reached `root.html.heex` (which has full conn.assigns access) but
NOT the inner `Layouts.app` function component called via
`apply(module, function, [assigns])` — the assigns map there only
contains what LayoutWrapper declared.

Add an explicit `attr :phoenix_kit_publishing_translations` so when
publishing's templates pass it through (separate commit on the
publishing side), it lands in the assigns forwarded to the host's
`Layouts.app`.
Replaces the publishing-specific `:phoenix_kit_publishing_translations`
attr on `app_layout/1` with a generic `:module_assigns` map attr.
Each key in the map is merged into the top-level assigns before
the host's `Layouts.app` renders, so a host's custom layout can
read e.g. `assigns[:phoenix_kit_publishing_translations]` from
publishing — or any future module's host-consumable key — without
core having to declare each one explicitly.

Phoenix function-component attrs must be declared in advance and
function-component layouts see ONLY declared attrs (not surrounding
`conn.assigns`). The single map attribute lets each external module
thread its own host-consumable keys through the boundary without
core touching the API every time a new module ships a host integration.

Merge policy: existing top-level assigns win over module-supplied
keys, so a module can't accidentally clobber `:flash`, `:current_user`,
or any other core-managed assign by colliding on the name.

Cross-repo: phoenix_kit_publishing's html.ex render branches switch
in the matching commit. The boundary test in
`phoenix_kit_publishing/test/.../language_switcher_exposure_test.exs`
continues to pin the chain end-to-end.
Mirrors the non-admin behaviour `Routes.path/2` already has: the primary
language renders prefixless (`/phoenix_kit/admin/users`); every other
locale keeps its segment (`/phoenix_kit/de/admin/users`).

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 and there's no redirect between the two shapes
(typing `/phoenix_kit/en/admin/users` still works).

Two helpers needed the same fix: the public `Routes.admin_path/2` API
(used by `<.language_switcher_dropdown>`'s admin-path branch and by host
code) and the private `build_admin_path/3` that handles `path/2`'s
internal admin dispatch.

Known tradeoff: switching locales inside admin via the UI today still
crosses the `:phoenix_kit_admin_locale` ↔ `:phoenix_kit_admin`
live_session boundary and forces a full-page reload. That's a separate
piece of work (TODO 1 in `dev_docs/primary_language_no_prefix_plan.md`)
covering router-macro restructure + a LiveView locale-rehydration hook
+ language-switcher push_patch refactor.

Plan + Gemini/Codex review notes: dev_docs/primary_language_no_prefix_plan.md.
Pure URL→default locale resolution. The previous fallback chain
(URL → session → default) became a sticky-locale trap after
`Routes.path/2` was changed to emit prefixless URLs for the primary
language: visiting `/foo/et/...` once stashed `"et"` in the session,
then every primary-prefixless URL inherited Estonian forever — even
after the user explicitly switched back. Refreshing didn't fix it
because the session still held `et`.

Changes:

- `mount_phoenix_kit_current_scope/3` (line ~781) — drop the
  `session_locale ||` step and the `Process.get(...)` fallback;
  resolution is now URL `:locale` param → default.
- `maybe_update_locale_from_params/2` (the "no locale in params"
  branch, line ~735) — drop the reserved-paths special case that
  preserved the session locale on prefixless admin/api paths. All
  prefixless paths now snap Gettext + assigns to the default locale,
  matching what `Routes.path/2` emits.
- `renew_session/1` (line ~138) — drop the locale-preservation
  dance across session renewal; nothing reads the key anymore.
- HTTP plug paths in `process_valid_locale/2` (line ~1869) and the
  user-preference-resolution branch (line ~1763) no longer write
  `:phoenix_kit_locale_base` to the session; it had no remaining
  consumers.

Logged-in users still get their persisted preference via
`user.custom_fields["preferred_locale"]`, which is independent of
session and untouched by this change.

Verified in Chrome:
  - `/phoenix_kit/et/users/log-in` renders "Logi sisse" (Estonian)
  - `/phoenix_kit/users/log-in` renders "Log in" / "Sign in" (English)
  - No need to clear cookies; refresh stays on whatever locale the
    URL implies.
Three additional helper-level assertions covering the prefix-strip
edge cases Codex flagged in review:

  * Explicit primary locale → prefixless (intended emission).
  * Explicit non-primary locale → prefixed (the same shape any
    legacy `/phoenix_kit/en/admin/...` URL takes — the dual-scope
    route emission keeps both shapes resolvable in the router).
  * Dialect-shaped code (e.g. "en-US") passes through unchanged.
    Callers normalise to base first via DialectMapper, but pinning
    this so a future "smart-strip" refactor doesn't accidentally
    treat "en-US" like "en" and drop the prefix.

The full router-level backcompat assertion (live HTTP probe of
`/phoenix_kit/en/admin/...` returning 200) would need an integration
test endpoint; deferred. Helper-level pins are the cheap insurance.
Closes the plug-vs-LV asymmetry Codex flagged: the HTTP plug used
to consult `user.custom_fields["preferred_locale"]` when the URL
had no `:locale` segment, while the LV mount path (changed in
`fecf3d3e`) went straight to default. A logged-in user with
preferred_locale=et visiting `/phoenix_kit/admin/foo` would see
Estonian for the initial HTTP response, then English after the
LV mount snapped to default — visible content flash.

Pure URL → default now holds across both paths. The preference
field is still WRITTEN by the language-switcher click handler
(`save_user_locale_preference/2`) so the data is preserved for
any future feature that wants to re-enable preference reading;
it just isn't read for routing today.

Also removes the now-dead `get_user_preferred_locale/1` helper.

Verified in Chrome:
  - `/phoenix_kit/et/users/log-in` → "Logi sisse" (Estonian)
  - `/phoenix_kit/users/log-in` → "Log in" / "Sign in" (English)
  - No flash on either route.
Closes the dialect-preference leak Codex flagged: even though the
plug + LV mount were both URL-driven for the BASE locale, all six
`DialectMapper.resolve_dialect/2` callers in `phoenix_kit_web/users/auth.ex`
still passed the current user, which let `DialectMapper`'s preferred-locale
clause upgrade the URL-derived base (e.g. "en") to a user-preferred
dialect ("en-GB") for logged-in users.

Effect under the old code: a user with `preferred_locale="en-GB"`
visiting `/phoenix_kit/admin/foo` (prefixless, primary base = "en")
got dialect "en-GB" via the dialect resolver — contradicting the
"URL is the only source of truth for routing" semantic the boss
asked for.

Fix: all six routing-path calls drop the user arg and use the
arity-1 `DialectMapper.resolve_dialect/1` (default mapping, no
preference upgrade). The dialect helper itself is unchanged — code
that legitimately wants user-preferred dialect (none in the routing
chain today) can still call the arity-2 form explicitly.

Cleanup: `get_user_for_locale_resolution/1` had no remaining callers
after the user args were removed; deleted.

Stale-comment fixes Codex flagged:

- `routes.ex:40`: the "admin paths ALWAYS get locale prefix" comment
  no longer matched `build_admin_path/3`'s primary-prefixless behaviour.
- `auth.ex:1846`: matching "admin paths keep the locale in the URL"
  comment in `process_valid_locale/2` updated to explain WHY the
  default-locale-redirect skips admin paths today (don't canonicalise
  across the still-split `:phoenix_kit_admin_locale` ↔
  `:phoenix_kit_admin` live_session boundary).
- `auth.ex:1856`: matching wording on `admin_request?/1` updated.

`preferred_locale` is now WRITTEN (by the switcher hook) but read by
nothing in the routing chain. The field is preserved for future
features but functionally inert today.
`position: fixed` on the kebab menu would be interpreted relative to
the nearest containing block, not the viewport. Inside a `<dialog>`
on the top layer (or any ancestor with `transform`/`contain`/`filter`)
this shoves the menu hundreds of pixels off-screen instead of
anchoring under the trigger.

Surfaced by `phoenix_kit_projects` PR BeamLabEU#9's `PopupHostLive` — assignment
edit kebabs inside the modal frame rendered far right of the viewport
on the host application. Reproduced locally via the parent app's
`/projects-emit-demo` route.

Fix portals `[data-row-menu-content]` to `document.body` in `_open()`
before measuring, restores it to its original parent (tracked at
`mounted()`) in `_close()` so LiveView's morphdom patching keeps
finding the element on subsequent diffs. Without restoration the
menu would orphan on `<body>` and LV would re-create it inside the
row, doubling the DOM.
@ddon ddon merged commit 38d4dfc into BeamLabEU:dev May 19, 2026
ddon pushed a commit that referenced this pull request May 19, 2026
Post-merge follow-ups on PR #551:

- RowMenu: give the floating menu a stable id and add an `updated()`
  hook callback that drops the duplicate <ul> morphdom re-creates when
  a server diff hits a row whose menu is portaled to <body>.
- Collapse `DialectMapper.resolve_dialect/2` to `/1` — the user-aware
  arity was unreachable and an active foot-gun (URL is authoritative).
- Remove dead `User.preferred_locale_changeset/2` and
  `User.get_preferred_locale/1`; refresh docs, doctests, tests, and the
  now-stale `resolve_dialect/2` references in auth.ex comments.
- Add `dev_docs/primary_language_no_prefix_plan.md` — the file PR #551
  references in several code comments but never committed; documents
  TODO 1 (unify the admin live_sessions) as an implementation plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ddon pushed a commit that referenced this pull request May 19, 2026
PR #551 (URL-authoritative locale, prefixless admin URLs, RowMenu
portal, module_assigns, publishing url_prefix fix) merged after
v1.7.113 was tagged and never got a CHANGELOG entry. It ships in the
1.7.114 release, so fold its changes into that section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mdon added a commit to mdon/phoenix_kit that referenced this pull request May 19, 2026
Lifts the locale-prefix-on-primary-language behavior from a
publishing-module setting into a single site-wide toggle on the
core Languages admin page (`/admin/settings/languages`). All URL
generators in the workspace — `Routes.path/1`, `Routes.admin_path/2`,
the sitemap, publishing's HTML builders, and publishing's
canonical-redirect controller — now honor the same source of truth.

## Why

PR BeamLabEU#551 made admin URLs unconditionally prefixless for the primary
language. Publishing's public URLs were governed by a separate
toggle (`publishing_default_language_no_prefix`, default off), and
the sitemap honored neither — it always emitted `/en/blog/post` in
multilang mode regardless of the setting. The boss flagged both:
the setting should be site-wide, and the sitemap should respect it.

## What changed

- **New core setting** `default_language_no_prefix` (boolean,
  default `false`) on `PhoenixKit.Modules.Languages`. Surfaced as a
  daisyUI toggle in a new "URL Behavior" card on the Languages
  admin page, above the existing "Frontend Language Switcher" card.
- **`Routes.path/1` + `admin_path/2`** (`lib/phoenix_kit/utils/routes.ex`):
  the unconditional primary-locale strip from PR BeamLabEU#551 is now gated
  on `Languages.default_language_no_prefix?/0` via a new
  `prefixless_primary?/0` private helper. Same defensive rescue +
  mix-task fallback as `get_default_language_base/0`.
- **Sitemap fix** (`lib/modules/sitemap/sources/publishing.ex`):
  `build_group_path/3` now skips the language segment for the
  primary language when the setting is on. Previously it always
  emitted the prefix in multilang mode.
- **Legacy migration** (`Languages.migrate_legacy/0`): on first
  call, copies the value from the legacy
  `publishing_default_language_no_prefix` key to the new
  `default_language_no_prefix` key. Idempotent — once the new key
  is set, subsequent runs are no-ops. Pairs with the publishing-
  side delegate in the matching publishing PR.

## Migration safety

Default is `false`, matching the historical publishing default and
keeping existing installs' indexed public URLs stable on upgrade.
Sites that previously toggled the publishing setting get migrated
on first `migrate_legacy/0` run — they keep their explicit choice
without admin action. Admin URL emission flips: PR BeamLabEU#551 emitted
prefixless unconditionally for the primary locale; this PR honors
the new setting (default off), so primary admin URLs grow back the
`/en/` prefix unless the admin opts in. Both URL shapes still
resolve at the router (dual-scope admin emission), so the only
visible change is the *emitted* shape — bookmarks and external
links keep working.

## Tests

- `test/phoenix_kit/utils/routes_test.exs` updated to cover the new
  default-off behavior (no-DB rescue → primary keeps prefix).
- `test/integration/languages/default_language_no_prefix_test.exs`
  added (DB-backed): setter round-trips, Routes helpers across both
  setting states, `migrate_legacy/0` no-op + backfill + idempotency.
- `test/phoenix_kit_web/components/core/language_switcher_test.exs`
  assertions updated to match the new default.

`mix test` clean (1308 tests, 0 failures, 11 doctests).
`mix compile --warnings-as-errors`, `mix credo --strict`,
`mix deps.unlock --check-unused`, `mix format --check-formatted` all
clean.

## Follow-up surfaced (not in this PR)

`phoenix_kit_entities/lib/phoenix_kit_entities/url_resolver.ex` has
the same site-wide-setting blind spot in two helpers + three call
sites — `build_path_with_language/3` (sitemap, always prefixes
primary) and `add_public_locale_prefix/2` (public URLs, always
strips primary). Surface to the entities owner before claiming
site-wide unification is complete.
mdon added a commit to mdon/phoenix_kit that referenced this pull request May 19, 2026
…fix` pass

Three follow-up fixes surfaced by browser testing + an ast-grep
sweep of all URL-emission sites:

## `process_valid_locale/2` in users/auth.ex

The post-PR BeamLabEU#551 plug unconditionally 301-redirected
`/<default>/...` → `/...` for non-admin paths. With the new setting
OFF (the default — `/<default>/...` is canonical), the 301 was
firing on every primary-language request **including form POSTs**,
which silently discarded the POST body. Browser test caught this:
login was completely broken with the default setting.

Fixed by gating the redirect on `prefixless_primary?/0`. With setting
OFF the prefixed shape is canonical and never gets stripped; with ON
the strip restores the canonical-redirect behavior from PR BeamLabEU#551.

## Sitemap sources `static.ex` + `posts.ex`

Both had the same bug as the publishing sitemap source I fixed in
the original commit — `build_path_with_language/3` accepted an
`_is_default` parameter but ignored it, emitting the prefix for the
primary language in multilang mode regardless of the setting.
Found by `ast-grep --lang elixir --pattern '"/#{$LOCALE}#{$PATH}"'`.

Both follow the publishing source's pattern: `cond` block with
explicit skip rules (nil language, single-language mode, primary +
setting-on), prefix in all other cases.

## Browser verification

Toggled the setting on the Languages admin page, regenerated the
sitemap, and confirmed:

- Setting OFF → primary entries get `/en/`, non-primary get
  their own prefix
- Setting ON → primary entries drop `/en/`, non-primary unchanged

`/en/db-test-1` 301-redirects to `/db-test-1` when setting is ON
(canonical redirect intact); the same URL serves directly when
setting is OFF (no redirect, POST bodies safe).

## Sweep coverage

ast-grep + grep across all 14 phoenix_kit-* repos for hardcoded
`/en/` segments, manual locale concatenation, raw `redirect(to:)`,
hreflang/canonical emission, `~p` verified routes, og:url, and
RSS/Atom feeds. No additional misses — every other URL builder
either uses `Routes.path/1` (now gated) or the publishing /
entities helpers (already delegated to the core setting).

`mix test`: 1308 tests, 0 failures, 11 doctests.
`mix compile --warnings-as-errors`, `mix credo --strict`,
`mix format --check-formatted`, `mix deps.unlock --check-unused`
all clean.
mdon added a commit to mdon/phoenix_kit that referenced this pull request May 19, 2026
Surfaced by a deeper ast-grep + grep sweep across all repos for
locale/language URL-emission patterns. Every other site I checked
already routes through `Routes.path/admin_path` (gated) or the
publishing/entities helpers (delegated). One outlier:

`redirect_invalid_locale/2` (`lib/phoenix_kit_web/users/auth.ex`)
strips an invalid locale segment from the URL and redirects to
the cleaned path. It always emitted the *prefixless* shape, which
was correct under PR BeamLabEU#551 (always-strip primary) but inconsistent
with the new setting OFF (default) where the canonical primary
shape is prefixed.

Example: `/phoenix_kit/xx/admin/users` (xx = invalid locale) was
redirecting to `/phoenix_kit/admin/users` regardless of setting.
With setting OFF, the rest of the app emits
`/phoenix_kit/en/admin/users`, so the redirect was landing on a
non-canonical shape. Both shapes resolve at the router (dual-scope
admin emission), but the inconsistency means an external link
with an invalid locale lands users on a path that doesn't match
the canonical URL emitted everywhere else in the app.

Fixed by gating the replacement segment on `prefixless_primary?/0`:

- Setting ON  → strip entirely (canonical primary is prefixless)
- Setting OFF → swap the invalid segment for `/<default_base>/`
  (canonical primary is prefixed)

## Sweep coverage (no additional misses)

- All `def *path*` / `def *url*` / `def *href*` builders that take
  a locale param — verified to route through gated helpers.
- All `String.replace(..., "/#{locale}/", ...)` sites — only used
  in invalid-locale stripping (above) and the now-gated canonical
  redirect.
- All `~p` verified routes — static assets only, no locale.
- All `og:url` / canonical / hreflang emitters — use
  `PublishingHTML.build_post_url` or `UrlResolver.public_url`,
  both gated.
- All `<.link navigate="/admin/...">` and `<.pk_link>` — pk_link
  routes through `Routes.path/1`; the one bare `<.link>` hit was
  inside a moduledoc example, not runtime code.
- Language-switcher dropdown's `resolve_url/3` →
  `generate_base_code_url/2` — uses `Routes.admin_path` and
  `Routes.path`, both gated.

No remaining misses across phoenix_kit, publishing, entities,
projects, ai, catalogue, newsletters, staff, comments,
user_connections, legal, sync, emails, posts, locations, or
hello_world.

`mix test`: 1308 tests, 0 failures, 11 doctests.
`mix compile --warnings-as-errors`, `mix credo --strict`,
`mix format --check-formatted`, `mix deps.unlock --check-unused`
all clean.
mdon added a commit to mdon/phoenix_kit that referenced this pull request May 20, 2026
…fix` pass

Three follow-up fixes surfaced by browser testing + an ast-grep
sweep of all URL-emission sites:

## `process_valid_locale/2` in users/auth.ex

The post-PR BeamLabEU#551 plug unconditionally 301-redirected
`/<default>/...` → `/...` for non-admin paths. With the new setting
OFF (the default — `/<default>/...` is canonical), the 301 was
firing on every primary-language request **including form POSTs**,
which silently discarded the POST body. Browser test caught this:
login was completely broken with the default setting.

Fixed by gating the redirect on `prefixless_primary?/0`. With setting
OFF the prefixed shape is canonical and never gets stripped; with ON
the strip restores the canonical-redirect behavior from PR BeamLabEU#551.

## Sitemap sources `static.ex` + `posts.ex`

Both had the same bug as the publishing sitemap source I fixed in
the original commit — `build_path_with_language/3` accepted an
`_is_default` parameter but ignored it, emitting the prefix for the
primary language in multilang mode regardless of the setting.
Found by `ast-grep --lang elixir --pattern '"/#{$LOCALE}#{$PATH}"'`.

Both follow the publishing source's pattern: `cond` block with
explicit skip rules (nil language, single-language mode, primary +
setting-on), prefix in all other cases.

## Browser verification

Toggled the setting on the Languages admin page, regenerated the
sitemap, and confirmed:

- Setting OFF → primary entries get `/en/`, non-primary get
  their own prefix
- Setting ON → primary entries drop `/en/`, non-primary unchanged

`/en/db-test-1` 301-redirects to `/db-test-1` when setting is ON
(canonical redirect intact); the same URL serves directly when
setting is OFF (no redirect, POST bodies safe).

## Sweep coverage

ast-grep + grep across all 14 phoenix_kit-* repos for hardcoded
`/en/` segments, manual locale concatenation, raw `redirect(to:)`,
hreflang/canonical emission, `~p` verified routes, og:url, and
RSS/Atom feeds. No additional misses — every other URL builder
either uses `Routes.path/1` (now gated) or the publishing /
entities helpers (already delegated to the core setting).

`mix test`: 1308 tests, 0 failures, 11 doctests.
`mix compile --warnings-as-errors`, `mix credo --strict`,
`mix format --check-formatted`, `mix deps.unlock --check-unused`
all clean.
mdon added a commit to mdon/phoenix_kit that referenced this pull request May 20, 2026
Surfaced by a deeper ast-grep + grep sweep across all repos for
locale/language URL-emission patterns. Every other site I checked
already routes through `Routes.path/admin_path` (gated) or the
publishing/entities helpers (delegated). One outlier:

`redirect_invalid_locale/2` (`lib/phoenix_kit_web/users/auth.ex`)
strips an invalid locale segment from the URL and redirects to
the cleaned path. It always emitted the *prefixless* shape, which
was correct under PR BeamLabEU#551 (always-strip primary) but inconsistent
with the new setting OFF (default) where the canonical primary
shape is prefixed.

Example: `/phoenix_kit/xx/admin/users` (xx = invalid locale) was
redirecting to `/phoenix_kit/admin/users` regardless of setting.
With setting OFF, the rest of the app emits
`/phoenix_kit/en/admin/users`, so the redirect was landing on a
non-canonical shape. Both shapes resolve at the router (dual-scope
admin emission), but the inconsistency means an external link
with an invalid locale lands users on a path that doesn't match
the canonical URL emitted everywhere else in the app.

Fixed by gating the replacement segment on `prefixless_primary?/0`:

- Setting ON  → strip entirely (canonical primary is prefixless)
- Setting OFF → swap the invalid segment for `/<default_base>/`
  (canonical primary is prefixed)

## Sweep coverage (no additional misses)

- All `def *path*` / `def *url*` / `def *href*` builders that take
  a locale param — verified to route through gated helpers.
- All `String.replace(..., "/#{locale}/", ...)` sites — only used
  in invalid-locale stripping (above) and the now-gated canonical
  redirect.
- All `~p` verified routes — static assets only, no locale.
- All `og:url` / canonical / hreflang emitters — use
  `PublishingHTML.build_post_url` or `UrlResolver.public_url`,
  both gated.
- All `<.link navigate="/admin/...">` and `<.pk_link>` — pk_link
  routes through `Routes.path/1`; the one bare `<.link>` hit was
  inside a moduledoc example, not runtime code.
- Language-switcher dropdown's `resolve_url/3` →
  `generate_base_code_url/2` — uses `Routes.admin_path` and
  `Routes.path`, both gated.

No remaining misses across phoenix_kit, publishing, entities,
projects, ai, catalogue, newsletters, staff, comments,
user_connections, legal, sync, emails, posts, locations, or
hello_world.

`mix test`: 1308 tests, 0 failures, 11 doctests.
`mix compile --warnings-as-errors`, `mix credo --strict`,
`mix format --check-formatted`, `mix deps.unlock --check-unused`
all clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants