lib/bylaw/html/check/prefer_button_for_action.ex

defmodule Bylaw.HTML.Check.PreferButtonForAction do
  @moduledoc """
  Validates that rendered HTML uses buttons for non-navigation actions.

  This check inspects rendered `<a>` elements with `phx-click` and flags
  placeholder action hrefs. Use an anchor for durable navigation, and use a
  button for actions handled by events.

  ## Examples

  Bad:

      <a href="#" phx-click="save">Save</a>

  Why this is bad:

  The element looks like a link, but the placeholder `href` has no durable
  destination. The real behavior is the event action attached to `phx-click`.

  Better:

      <button type="button" phx-click="save">Save</button>

  Why this is better:

  A button communicates that the element performs an action instead of
  navigating to another resource.

  Bad:

      <a href="javascript:void(0)" phx-click="open">Open</a>

  Why this is bad:

  A JavaScript placeholder suppresses normal link behavior and makes the anchor
  an action control.

  Better:

      <button type="button" phx-click="open">Open</button>

  Why this is better:

  The browser exposes the element as a control without pretending there is a
  navigable URL.

  ## Notes

  This check only flags rendered anchors that have both `phx-click` and a
  placeholder action href. It allows real navigation anchors, including anchors
  that also use `phx-click` for secondary behavior such as analytics:

      <a href="/settings" phx-click="track">Settings</a>

  It also allows fragment links without `phx-click`, such as
  `<a href="#details">Details</a>`.

  ## Options

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

      Bylaw.HTML.Check.PreferButtonForAction

  ## 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

  @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("a[phx-click][href]")
    |> Enum.filter(&action_href?/1)
    |> Enum.map(&issue_for/1)
    |> result()
  end

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

  defp action_href?(element) do
    element
    |> href_value()
    |> action_href_value?()
  end

  defp href_value(element) do
    element
    |> LazyHTML.attribute("href")
    |> List.first()
  end

  defp action_href_value?(nil), do: false

  defp action_href_value?(href) do
    href =
      href
      |> String.trim()
      |> String.downcase()

    href in ["", "#", "javascript:void(0)", "javascript:void(0);"]
  end

  defp issue_for(element) do
    %Issue{
      check: __MODULE__,
      message: "expected action links to use <button>; found <a> with phx-click and action href",
      tag: "a",
      snippet: LazyHTML.to_html(element)
    }
  end

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