Skip to main content

lib/mix/tasks/skua.gen.pages.ex

defmodule Mix.Tasks.Skua.Gen.Pages do
  @shortdoc "Scaffold a starter homepage + shared nav + authenticated dashboard"
  @moduledoc """
  Generates editable starter pages and wires their routes:

      mix skua.gen.pages

  Creates:

    * `<App>Web.SiteNav` — a shared top nav (Overview · Components, plus
      auth-aware links: a signed-in user's email + Log out, or Register / Sign
      in). It's injected into `Layouts.app`, so every `<Layouts.app>`-wrapped
      page (auth pages, dashboard) gets it, and rendered directly by the
      homepage (which has no layout of its own).
    * `<App>Web.HomeLive` at `/` — a full-viewport hero (the live Phoenix +
      Skua version badges above the big app-name heading) and a full-viewport,
      interactive showcase of real Skua components: clickable toasts, a dialog,
      drawer, popover, menu, and tooltip, plus cards, badges, an alert, tabs
      with a table and a list, an accordion, avatars, a progress bar, and a
      form with an input and select.
    * `<App>Web.DashboardLive` at `/dashboard` — an authenticated page with a
      left sidebar (Dashboard / Settings + Log out pinned to the bottom) and a
      content area of Skua stat cards.

  It also strips `phx.gen.auth`'s stock `menu menu-horizontal` nav from the root
  layout, so the shared `SiteNav` isn't duplicated by a second top bar.

  Run `mix skua.install` first (so the Skua components are imported) and, for
  the auth-aware nav + the dashboard's auth gate, `mix skua.gen.auth`.

  ## Routing

  `/` is pointed at `HomeLive` via `Skua.Install.Patches.router/1`. When auth is
  installed, `/` is then moved into the `:current_user` `live_session` (so the
  shared `SiteNav` is auth-aware on the homepage too — it reads `@current_scope`).
  `/dashboard` is inserted into the `:require_authenticated_user` `live_session`
  that phx.gen.auth generates, so it sits behind login. If auth isn't installed
  (no such `live_session`), the dashboard route is added to the plain `/` scope
  instead and a notice is printed — it then renders for everyone until you add
  auth.

  Idempotent: re-running overwrites the generated pages and leaves the routes,
  the `Layouts.app` nav injection, and the stripped stock auth menu in place.
  """
  use Mix.Task

  @impl Mix.Task
  def run(_args) do
    if Mix.Project.umbrella?() do
      Mix.raise("Run `mix skua.gen.pages` from inside your *_web app, not the umbrella root.")
    end

    app = to_string(Mix.Project.config()[:app])
    web = Macro.camelize(app) <> "Web"
    app_name = app |> String.split("_") |> Enum.map_join(" ", &String.capitalize/1)
    web_dir = Path.join("lib", "#{app}_web")

    Mix.shell().info([:cyan, "\n  skua.gen.pages\n", :reset])

    results = [
      write_site_nav(web_dir, app, web, app_name),
      write_home(web_dir, app, web, app_name),
      write_dashboard(web_dir, app, web, app_name),
      drop_legacy(web_dir),
      drop_stale_page_test(web_dir),
      inject_nav(Path.join([web_dir, "components", "layouts.ex"]), web),
      wrap_settings(web_dir, web),
      strip_auth_menu(Path.join([web_dir, "components", "layouts", "root.html.heex"])),
      inject_seo_meta(Path.join([web_dir, "components", "layouts", "root.html.heex"])),
      patch_router(Path.join(web_dir, "router.ex"))
    ]

    print(results)

    Mix.shell().info([
      :green,
      "\n  ✓ ",
      :reset,
      "Pages ready: / (HomeLive), /dashboard (DashboardLive), and a shared nav in\n      ",
      Path.join([web_dir, "site_nav.ex"]),
      " injected into Layouts.app.\n"
    ])
  end

  # Written at the module-convention path so they co-exist cleanly with the
  # router aliases (<Web>.HomeLive -> lib/<app>_web/home_live.ex, etc.).
  defp write_site_nav(web_dir, app, web, app_name) do
    path = Path.join(web_dir, "site_nav.ex")
    File.write!(path, Skua.Pages.site_nav_view(web, app_name, app))
    {:ok, path}
  end

  defp write_home(web_dir, app, web, app_name) do
    path = Path.join(web_dir, "home_live.ex")
    File.write!(path, Skua.Pages.home_view(web, app_name, app))
    {:ok, path}
  end

  defp write_dashboard(web_dir, app, web, app_name) do
    path = Path.join(web_dir, "dashboard_live.ex")
    File.write!(path, Skua.Pages.dashboard_view(web, app_name, app))
    {:ok, path}
  end

  # The stock phx.new page_controller_test asserts the default landing copy at
  # `/`, which we've repointed to HomeLive — remove it so `mix test` stays green.
  defp drop_stale_page_test(web_dir) do
    path =
      web_dir
      |> String.replace_prefix("lib/", "test/")
      |> Path.join("controllers/page_controller_test.exs")

    if File.exists?(path) do
      File.rm!(path)
      {:ok, path <> " (removed stale / test)"}
    else
      {:skip, path}
    end
  end

  # Drop a legacy live/home_live.ex so we never define HomeLive twice.
  defp drop_legacy(web_dir) do
    legacy = Path.join([web_dir, "live", "home_live.ex"])

    if File.exists?(legacy) do
      File.rm!(legacy)
      {:ok, legacy <> " (removed duplicate)"}
    else
      {:skip, legacy}
    end
  end

  defp inject_nav(path, web) do
    if File.exists?(path) do
      case Skua.Pages.Codemod.inject_site_nav(File.read!(path), web) do
        :skip -> {:skip, path}
        {:ok, new} -> File.write!(path, new) && {:ok, path <> " (SiteNav injected)"}
        {:manual, msg} -> {:manual, path, msg}
      end
    else
      {:manual, path, "layouts.ex not found — add <#{web}.SiteNav.site_nav /> to Layouts.app."}
    end
  end

  # Swap the phx.gen.auth Settings page's Layouts.app wrapper for the shared
  # dash_shell so Settings wears the same sidebar as the dashboard. A no-op
  # (skip) when auth isn't installed (no settings.ex).
  defp wrap_settings(web_dir, web) do
    case Path.wildcard(Path.join([web_dir, "live", "*_live", "settings.ex"])) do
      [path | _] ->
        case Skua.Pages.Codemod.wrap_settings_shell(File.read!(path), web) do
          :skip -> {:skip, path}
          {:ok, new} -> File.write!(path, new) && {:ok, path <> " (dash shell wrapped)"}
          {:manual, msg} -> {:manual, path, msg}
        end

      [] ->
        {:skip, Path.join([web_dir, "live", "user_live", "settings.ex"])}
    end
  end

  # Drop phx.gen.auth's stock `menu menu-horizontal` nav from the root layout so
  # it doesn't duplicate the shared SiteNav. A no-op (skip) when auth isn't
  # installed or the menu's already gone.
  defp strip_auth_menu(path) do
    if File.exists?(path) do
      case Skua.Pages.Codemod.strip_auth_menu(File.read!(path)) do
        :skip -> {:skip, path}
        {:ok, new} -> File.write!(path, new) && {:ok, path <> " (stock auth nav removed)"}
      end
    else
      {:skip, path}
    end
  end

  # Add `<Skua.Components.Meta.seo_meta/>` to the root layout <head>, driven by
  # per-page assigns. Public pages (HomeLive) set a description; scoped pages
  # (DashboardLive) set noindex — nothing is emitted by default.
  defp inject_seo_meta(path) do
    if File.exists?(path) do
      case Skua.Pages.Codemod.inject_seo_meta(File.read!(path)) do
        :skip -> {:skip, path}
        {:ok, new} -> File.write!(path, new) && {:ok, path <> " (SEO meta injected)"}
        {:manual, msg} -> {:manual, path, msg}
      end
    else
      {:manual, path,
       "root layout not found — add <Skua.Components.Meta.seo_meta/> to your <head>."}
    end
  end

  # Apply both router edits (point `/` at HomeLive, add the dashboard route)
  # sequentially, then write once. A `{:manual, msg}` from either step is
  # surfaced but doesn't block the other edit.
  defp patch_router(path) do
    if File.exists?(path) do
      original = File.read!(path)

      {c1, note1} = step(root_route(original))
      {c2, note2} = step(dashboard_route(c1))
      {c3, note3} = step(home_scope(c2))

      File.write!(path, c3)

      case Enum.reject([note1, note2, note3], &is_nil/1) do
        [] -> {:ok, path}
        notes -> {:manual, path, Enum.join(notes, " ")}
      end
    else
      {:manual, path, "router.ex not found"}
    end
  end

  # {:ok, content} | {:skip, content} -> {content, nil}; {:manual, msg} keeps the
  # prior content (caller threads it) and carries the note.
  defp step({:ok, content}), do: {content, nil}
  defp step({:skip, content}), do: {content, nil}
  defp step({:manual, msg, content}), do: {content, msg}

  defp root_route(content), do: normalize(Skua.Install.Patches.router(content), content)

  # Move `live "/", HomeLive` under the :current_user live_session so the shared
  # SiteNav is auth-aware on the homepage (it reads @current_scope). A no-op when
  # auth isn't installed. Codemod returns :skip | {:ok, _} | {:manual, _}.
  defp home_scope(content),
    do: normalize(Skua.Pages.Codemod.mount_home_in_scope(content), content)

  # Insert `live "/dashboard", DashboardLive, :index` into the authenticated
  # live_session phx.gen.auth ships. If that block is absent (no auth installed),
  # fall back to the plain `/` scope and flag it.
  defp dashboard_route(content) do
    cond do
      String.contains?(content, "DashboardLive") ->
        {:skip, content}

      Regex.match?(authed_session(), content) ->
        {:ok,
         Regex.replace(
           authed_session(),
           content,
           fn whole, indent ->
             whole <> ~s(live "/dashboard", DashboardLive, :index\n#{indent})
           end,
           global: false
         )}

      Regex.match?(home_route(), content) ->
        new =
          Regex.replace(
            home_route(),
            content,
            fn whole, indent ->
              whole <> "\n" <> indent <> ~s(live "/dashboard", DashboardLive, :index)
            end,
            global: false
          )

        {:manual,
         "no authenticated live_session found — /dashboard added to the PUBLIC scope; " <>
           "run `mix skua.gen.auth` and move it under :require_authenticated_user to gate it.",
         new}

      true ->
        {:manual,
         ~s(Add a dashboard route: live "/dashboard", DashboardLive, :index ) <>
           "(ideally inside your authenticated live_session).", content}
    end
  end

  # The opening of phx.gen.auth's authed live_session, with the first child line
  # captured by indentation so we can insert above it.
  defp authed_session do
    ~r/live_session :require_authenticated_user,\n[ \t]*on_mount: \[\{[^\]]+\}\] do\n([ \t]*)/
  end

  defp home_route, do: ~r/^([ \t]*)live "\/", HomeLive/m

  # Patches.router/1 returns :skip | {:ok, _} | {:manual, _}; map to the
  # 3-tuple-or-{tag, content} shape `step/1` threads, keeping content on :skip /
  # :manual so the next edit still runs.
  defp normalize(:skip, content), do: {:skip, content}
  defp normalize({:ok, new}, _content), do: {:ok, new}
  defp normalize({:manual, msg}, content), do: {:manual, msg, content}

  defp print(results) do
    Enum.each(results, fn
      {:ok, p} -> Mix.shell().info([:green, "  ✓ ", :reset, p])
      {:skip, p} -> Mix.shell().info([:faint, "  • already done — ", p, :reset])
      {:manual, p, m} -> Mix.shell().info([:yellow, "  ! ", :reset, p, ": ", m])
    end)
  end
end