lib/joby_kit/nav_patcher.ex

defmodule JobyKit.NavPatcher do
  @moduledoc false

  # Patches the host's `lib/<app>_web/components/layouts/app.html.heex`
  # to add nav links pointing at `/design` and `/custom-designs`.
  #
  # Strategy is similar to `phx.gen.auth`: find the first `</ul>` that
  # follows a `<nav>` or `<header>` opening tag and inject `<li>` items
  # right before it. Items are wrapped in
  # `<%!-- jobykit:nav-start --%>` / `<%!-- jobykit:nav-end --%>`
  # comments so subsequent runs detect and skip the insertion.
  #
  # Returns one of:
  #
  #   * `:patched`        — inserted the block.
  #   * `:unchanged`      — markers already present (idempotent re-run).
  #   * `:no_nav_found`   — no `<nav>`/`<header>` + `</ul>` pair in the
  #                         file. Caller should print a manual-add note.
  #   * `:missing`        — file doesn't exist (e.g. host customized
  #                         layouts in a different shape). Caller decides.

  @start_marker "<%!-- jobykit:nav-start --%>"
  @end_marker "<%!-- jobykit:nav-end --%>"

  @kit_links [
    {"Design", "/design"},
    {"Custom Designs", "/custom-designs"}
  ]

  @doc """
  Patches `path` to inject the kit's nav links before the first `</ul>`
  inside a `<nav>` or `<header>`. Idempotent.

  Pass `:links` (a list of `{label, href}` tuples) to override the
  default kit links.
  """
  def patch(path, opts \\ []) do
    links = Keyword.get(opts, :links, @kit_links)

    case File.read(path) do
      {:error, _} ->
        :missing

      {:ok, contents} ->
        cond do
          String.contains?(contents, @start_marker) ->
            :unchanged

          true ->
            case locate_insertion_point(contents) do
              nil ->
                :no_nav_found

              {before_text, after_text, indent} ->
                new_contents = before_text <> render_block(links, indent) <> after_text
                File.write!(path, new_contents)
                :patched
            end
        end
    end
  end

  @doc false
  def kit_links, do: @kit_links

  @doc false
  def start_marker, do: @start_marker

  @doc false
  def end_marker, do: @end_marker

  # Locate the first `</ul>` that comes after a `<nav>` or `<header>`
  # opening tag. Returns `{before, after, indent}` where `indent` is
  # the run of whitespace that precedes the `</ul>` (used for matching
  # the host's existing indentation).
  defp locate_insertion_point(contents) do
    with [{nav_start, _}] <- Regex.run(~r/<(?:nav|header)\b/, contents, return: :index),
         remainder <- binary_part(contents, nav_start, byte_size(contents) - nav_start),
         {ul_offset, _len} <- :binary.match(remainder, "</ul>") do
      ul_pos = nav_start + ul_offset
      indent = leading_indent(contents, ul_pos)

      {
        binary_part(contents, 0, ul_pos),
        binary_part(contents, ul_pos, byte_size(contents) - ul_pos),
        indent
      }
    else
      _ -> nil
    end
  end

  # Walk backward from `pos` until a non-space/tab char or a newline,
  # returning the whitespace run that begins the `</ul>` line.
  defp leading_indent(contents, pos) do
    {indent, _} =
      Enum.reduce_while((pos - 1)..0//-1, {"", false}, fn i, {acc, _} ->
        case binary_part(contents, i, 1) do
          " " -> {:cont, {" " <> acc, false}}
          "\t" -> {:cont, {"\t" <> acc, false}}
          "\n" -> {:halt, {acc, true}}
          _ -> {:halt, {"", true}}
        end
      end)

    indent
  end

  defp render_block(links, indent) do
    items =
      Enum.map(links, fn {label, href} ->
        """
        #{indent}<li>
        #{indent}  <.link navigate={~p"#{href}"}>#{label}</.link>
        #{indent}</li>\
        """
      end)
      |> Enum.join("\n")

    "#{indent}#{@start_marker}\n#{items}\n#{indent}#{@end_marker}\n#{indent}"
  end
end