lib/bylaw/html/check/prefer_link_for_navigation.ex

defmodule Bylaw.HTML.Check.PreferLinkForNavigation do
  @moduledoc """
  Validates that rendered HTML uses links for durable navigation.

  This check is intentionally narrow. It only inspects rendered non-`a`
  elements with `phx-click` attributes and only flags JSON LiveView JS command
  sequences containing `navigate` or `patch`.

  ## Examples

  Bad:

      <button phx-click='[["navigate",{"href":"/settings","replace":false}]]'>
        Settings
      </button>

  Why this is bad:

  The button performs durable navigation, but browsers and assistive technology
  cannot treat it like a link. Users lose normal link affordances such as
  opening in a new tab, copying the target URL, and seeing a destination.

  Better:

      <a href="/settings">Settings</a>

  Why this is better:

  The destination is represented as a link in the rendered HTML.

  Bad:

      <div phx-click='[["patch",{"href":"/users","replace":false}]]'>Users</div>

  Why this is bad:

  A non-interactive element is handling navigation. It needs extra keyboard and
  accessibility work and still does not expose a durable destination.

  Better:

      <a href="/users" data-phx-link="patch" data-phx-link-state="push">Users</a>

  Why this is better:

  LiveView patch navigation is still rendered as an anchor with an `href`.

  ## Notes

  This check only detects JSON-encoded LiveView JS command sequences in
  `phx-click` attributes. It flags `navigate` and `patch` commands on rendered
  elements other than `<a>`.

  Non-navigation `phx-click` events are allowed:

      <button type="button" phx-click="save">Save</button>
      <button type="button" phx-click='[["push",{"event":"save"}]]'>Save</button>

  Malformed or non-JSON `phx-click` values are ignored because this check only
  validates command sequences it can identify.

  ## Options

  This check has no check-specific options. Add the module directly to the
  explicit checks list:

      Bylaw.HTML.Check.PreferLinkForNavigation

  ## Usage

  Add this module to the explicit check list passed through `Bylaw.HTML`.
  See `Bylaw.HTML` for the full rendered HTML validation setup.
  """

  @behaviour Bylaw.HTML.Check

  alias Bylaw.HTML.Issue

  @navigation_ops ["navigate", "patch"]

  @doc """
  Implements the `Bylaw.HTML.Check` validation callback.
  """
  @impl Bylaw.HTML.Check
  @spec validate(Bylaw.HTML.Check.context()) :: Bylaw.HTML.Check.result()
  def validate(%{document: document}) do
    document
    |> LazyHTML.query("[phx-click]")
    |> Enum.flat_map(&issues_for_element/1)
    |> result()
  end

  def validate(context) do
    raise ArgumentError,
          "expected context to be a map with a parsed document, got: #{inspect(context)}"
  end

  defp issues_for_element(element) do
    tag = element_tag(element)

    element
    |> phx_click_value()
    |> navigation_op()
    |> issue_list(tag, element)
  end

  defp element_tag(element) do
    element
    |> LazyHTML.tag()
    |> List.first()
  end

  defp phx_click_value(element) do
    element
    |> LazyHTML.attribute("phx-click")
    |> List.first()
  end

  defp navigation_op(nil), do: nil

  defp navigation_op(phx_click) do
    with {:ok, commands} <- Jason.decode(phx_click),
         true <- is_list(commands) do
      Enum.find_value(commands, &navigation_command/1)
    else
      _not_navigation -> nil
    end
  end

  defp navigation_command([operation | _rest]) when operation in @navigation_ops, do: operation
  defp navigation_command(_command), do: nil

  defp issue_list(_operation, "a", _element), do: []
  defp issue_list(_operation, nil, _element), do: []
  defp issue_list(nil, _tag, _element), do: []

  defp issue_list(operation, tag, element) do
    [
      %Issue{
        check: __MODULE__,
        message:
          "expected durable navigation to use <a>; found phx-click #{operation} on <#{tag}>",
        tag: tag,
        snippet: element_snippet(element)
      }
    ]
  end

  defp element_snippet(element) do
    LazyHTML.to_html(element)
  end

  defp result([]), do: :ok
  defp result(issues), do: {:error, issues}
end