Current section
Files
Jump to
Current section
Files
lib/skua/pages/codemod.ex
defmodule Skua.Pages.Codemod do
@moduledoc false
# Pure string transforms for `mix skua.gen.pages`, in the same shape as
# `Skua.Install.Patches` / `Skua.Auth.Patches`. Each `*/_` transform returns:
#
# * `:skip` — already applied (idempotent no-op)
# * `{:ok, content}` — the new file content to write
# * `{:manual, msg}` — couldn't safely patch; surface `msg` to the user
#
# The job here is to inject the shared `<App>Web.SiteNav` into the generated
# `Layouts.app/1`, so every `<Layouts.app>`-wrapped page (auth pages, the
# dashboard) renders the same nav. The homepage renders SiteNav directly (it
# doesn't go through Layouts.app), so the nav shows up on every route.
@doc """
Inject `<#{}Web.SiteNav.site_nav .../>` as the first child of `Layouts.app/1`.
Idempotent (skips if SiteNav is already there). Targets only the `~H` of
`def app(assigns) do` — never `flash_group/1` or `theme_toggle/1`, which also
carry `~H` sigils. Uses `assigns[:current_scope]` (safe bracket access) so it
works even if a page doesn't assign a scope. Falls back to `{:manual, msg}`
rather than risk corrupting an unexpected layout.
"""
def inject_site_nav(content, web_mod) do
call = ~s(<#{web_mod}.SiteNav.site_nav current_scope={assigns[:current_scope]} />)
# Match `def app(assigns) do` then its opening `~H"""` (with optional `#{}`
# leading whitespace), capturing the indentation so the injected tag lines
# up with the rest of the template.
anchor = ~r/(def app\(assigns\) do\n([ \t]*)~H"""\n)/
cond do
String.contains?(content, "SiteNav.site_nav") ->
:skip
Regex.match?(anchor, content) ->
new =
Regex.replace(anchor, content, fn _whole, head, indent ->
head <> indent <> call <> "\n"
end)
{:ok, new}
true ->
{:manual,
"Couldn't find `def app(assigns)` in layouts.ex. Add `#{call}` as the first " <>
"child of your `Layouts.app` template so the shared nav renders."}
end
end
@doc """
Remove the stock `phx.gen.auth` auth menu so it doesn't duplicate `SiteNav`.
`phx.gen.auth` injects a top-of-page `<ul class="menu menu-horizontal …">` with
Register / Log in (or the user's email / Settings / Log out) links. In a
Phoenix 1.8 app that block lives in the **root layout**
(`components/layouts/root.html.heex`) — it wraps every page, including the
homepage — so once we render `SiteNav` everywhere, the stock menu is a
duplicate top nav.
Operates on whatever file content you hand it (root layout or `layouts.ex`),
matching the `menu menu-horizontal` `<ul>…</ul>` and dropping it along with its
leading indentation/newline. The block has no nested `<ul>`, so the lazy match
to the first `</ul>` is exact.
* `:skip` — no such block (already stripped, or never present)
* `{:ok, content}` — the block removed
Idempotent: re-running after a strip returns `:skip`. Never returns `{:manual,
_}` — absence is a safe no-op, and we deliberately don't touch anything that
isn't the menu-horizontal `<ul>`.
"""
def strip_auth_menu(content) do
# The leading whitespace before the <ul> is captured so the whole line is
# removed cleanly; `[\s\S]*?` is a lazy any-including-newline match to the
# first `</ul>` (the block nests <li>s, never another <ul>).
block = ~r/[ \t]*<ul class="menu menu-horizontal[^"]*">[\s\S]*?<\/ul>\n?/
if Regex.match?(block, content) do
{:ok, Regex.replace(block, content, "", global: false)}
else
:skip
end
end
@doc """
Move `live "/", HomeLive` into phx.gen.auth's `:current_user` `live_session`.
Once the stock auth menu is stripped (see `strip_auth_menu/1`), the only place
a signed-in user's email / Log out can show on the homepage is `SiteNav`. But
`SiteNav` reads `@current_scope`, and a bare `live "/", HomeLive` route never
runs an `on_mount` hook, so the scope is always `nil` and the nav shows the
guest links even when logged in. Moving the route under the `mount_current_scope`
live_session makes `SiteNav` auth-aware on `/` too.
* `:skip` — no `:current_user` live_session (auth not installed) or
HomeLive is already inside it (idempotent)
* `{:ok, content}` — the route relocated into the live_session
* `{:manual, msg}` — the live_session exists but the bare home route wasn't
found where expected; surfaces a hint, touches nothing
Only acts when the `:current_user` live_session exists — without auth there's
no scope to mount, and the homepage works fine as a plain route.
"""
def mount_home_in_scope(content) do
# The bare home route, captured with its leading indentation so we can strip
# the whole line (and its trailing newline) cleanly.
bare = ~r/\n[ \t]*live "\/", HomeLive(?:, :[a-z_]+)?\n/
# The opening of phx.gen.auth's public live_session, capturing the indent of
# its first child so the relocated route lines up.
session = ~r/(live_session :current_user,\n[ \t]*on_mount: \[\{[^\]]+\}\] do\n)([ \t]*)/
cond do
# Already moved: a HomeLive route sits *after* the live_session opener.
not Regex.match?(session, content) ->
:skip
home_inside_current_user_session?(content) ->
:skip
Regex.match?(bare, content) ->
# 1) drop the bare route, 2) insert it as the first child of the session.
without = Regex.replace(bare, content, "\n", global: false)
new =
Regex.replace(session, without, fn _whole, head, indent ->
head <> indent <> ~s(live "/", HomeLive\n) <> indent
end)
{:ok, new}
true ->
{:manual,
~s(Couldn't find `live "/", HomeLive` to move into the :current_user ) <>
"live_session. Put it inside that block so the homepage nav is auth-aware."}
end
end
# True when a `live "/", HomeLive` appears after the `:current_user`
# live_session opener (i.e. it's already been relocated).
defp home_inside_current_user_session?(content) do
case Regex.run(~r/live_session :current_user,/, content, return: :index) do
[{start, _}] ->
rest = binary_part(content, start, byte_size(content) - start)
Regex.match?(~r/live "\/", HomeLive/, rest)
_ ->
false
end
end
end