lib/bylaw/html.ex

defmodule Bylaw.HTML do
  @moduledoc """
  Validates rendered HTML strings with explicit checks.

  `validate_html/2` parses the rendered HTML once, runs the checks you choose,
  and returns `:ok` or `{:error, issues}`. Bylaw does not read application
  config or choose default checks for the caller.

  ## Usage

  Choose the checks you want to enforce and pass them with the rendered HTML
  string:

      html = render(view)

      checks = [
        Bylaw.HTML.Check.RequireLinkHref,
        Bylaw.HTML.Check.PreferButtonForAction,
        Bylaw.HTML.Check.PreferLinkForNavigation,
        Bylaw.HTML.Check.RequireImageAlt,
        Bylaw.HTML.Check.RequireButtonType,
        Bylaw.HTML.Check.RequireInputAutocomplete,
        Bylaw.HTML.Check.NoInlineStyle
      ]

      assert :ok = Bylaw.HTML.validate_html(html, checks)

  When validation fails, `validate_html/2` returns every issue found by the
  enabled checks:

      case Bylaw.HTML.validate_html(html, checks) do
        :ok -> :ok
        {:error, issues} -> flunk(inspect(issues, pretty: true))
      end

  ## Built-in checks

  Built-in checks live under `Bylaw.HTML.Check.*`. Each check module documents
  its own examples, notes, options, and copyable check specs.

  ## Notes

  The validation boundary is the rendered HTML string. Checks can see the
  browser-facing markup, but they do not know which source component or template
  produced it.

  ## Examples

      iex> Bylaw.HTML.validate_html(~s(<a href="/settings">Settings</a>), [])
      :ok

      iex> {:error, [issue]} =
      ...>   Bylaw.HTML.validate_html(
      ...>     ~s(<button phx-click='[["navigate",{"href":"/settings","replace":false}]]'>Settings</button>),
      ...>     [Bylaw.HTML.Check.PreferLinkForNavigation]
      ...>   )
      iex> issue.check
      Bylaw.HTML.Check.PreferLinkForNavigation
  """

  alias Bylaw.CheckRunner
  alias Bylaw.HTML.Issue

  @type check :: module()
  @type checks :: list(check())

  @doc """
  Validates rendered `html` with the explicit `checks` list.

  Returns `:ok` when every check passes. Returns `{:error, issues}` when one or
  more issues are found. Validation failures do not raise.

  `checks` must be an explicit list of modules implementing
  `Bylaw.HTML.Check`. Bylaw does not choose default HTML checks or read them
  from application config.
  """
  @spec validate_html(String.t(), checks()) :: :ok | {:error, nonempty_list(Issue.t())}
  def validate_html(html, checks) when is_binary(html) and is_list(checks) do
    checks
    |> normalize_checks!()
    |> validate_parsed_html(html)
  end

  def validate_html(html, _checks) when not is_binary(html) do
    raise ArgumentError, "expected html to be a string, got: #{inspect(html)}"
  end

  def validate_html(_html, checks) do
    raise ArgumentError, "expected checks to be a list, got: #{inspect(checks)}"
  end

  defp validate_parsed_html([], _html), do: :ok

  defp validate_parsed_html(checks, html) do
    case parse_fragment(html) do
      {:ok, document} ->
        context = %{html: html, document: document}

        checks
        |> Enum.flat_map(&issues_for_check(&1, context))
        |> result()

      {:error, _reason} ->
        {:error, [parse_issue(html)]}
    end
  end

  defp parse_fragment(html) do
    {:ok, LazyHTML.from_fragment(html)}
  rescue
    _exception -> {:error, :invalid_html}
  end

  defp normalize_checks!(checks) do
    Enum.map(checks, &ensure_check!/1)
  end

  defp ensure_check!(check) when is_atom(check) do
    with {:module, ^check} <- Code.ensure_loaded(check),
         true <- function_exported?(check, :validate, 1) do
      check
    else
      _not_a_check ->
        raise ArgumentError, "expected #{inspect(check)} to be an HTML check module"
    end
  end

  defp ensure_check!(check) do
    raise ArgumentError, "expected check to be a module, got: #{inspect(check)}"
  end

  defp issues_for_check(check, context) do
    result = check.validate(context)

    apply(CheckRunner, :result!, [check, result, Issue, 1])
  end

  defp parse_issue(html) do
    %Issue{
      check: __MODULE__,
      message: "failed to parse rendered HTML",
      snippet: excerpt(html)
    }
  end

  defp excerpt(html) do
    if String.length(html) > 160 do
      String.slice(html, 0, 160) <> "..."
    else
      html
    end
  end

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