lib/bylaw/credo/check/heex/require_target_blank_rel.ex

defmodule Bylaw.Credo.Check.HEEx.RequireTargetBlankRel do
  @moduledoc """
  Requires static HEEx/HTML links with `target="_blank"` to define safe `rel`.

  ## Examples

  Embedded `~H` templates are checked during normal Credo runs over Elixir
  files. Standalone `.html.heex` templates require enabling
  `Bylaw.Credo.Plugin.HEExSources` in Credo's `plugins` configuration.
  Avoid:

        ~H\"\"\"
        <a href="https://example.com" target="_blank">Example</a>
        <a href="https://example.com" target="_blank" rel="external">Example</a>
        \"\"\"
  Prefer:

        ~H\"\"\"
        <a href="https://example.com" target="_blank" rel="noopener">Example</a>
        <a href="https://example.com" target="_blank" rel="external noopener noreferrer">Example</a>
        <a href="https://example.com" target={@target} rel={@rel}>Example</a>
        \"\"\"

  ## Notes

  Embedded `~H` templates in `.ex` and `.exs` files are checked by Credo's normal source traversal. Standalone `.html.heex` templates are checked when `Bylaw.Credo.Plugin.HEExSources` is enabled in `.credo.exs`.

  This check uses static HEEx token analysis, so it reports only patterns visible in the template source.

  ## Options

  This check has no check-specific options. Configure it with an empty option list.

  ## Usage

  Add this check to Credo's `checks:` list in `.credo.exs`:

  ```elixir
  %{
    configs: [
      %{
        name: "default",
        checks: [
          {Bylaw.Credo.Check.HEEx.RequireTargetBlankRel, []}
        ]
      }
    ]
  }
  ```
  """

  use Credo.Check,
    base_priority: :high,
    category: :warning,
    explanations: [check: @moduledoc]

  alias Bylaw.Credo.Heex

  @message ~s(Links with target="_blank" must define rel with noopener.)
  @doc false
  @impl Credo.Check
  def run(%Credo.SourceFile{} = source_file, params \\ []) do
    issue_meta = IssueMeta.for(source_file, params)

    source_file
    |> Heex.templates()
    |> Enum.flat_map(&Heex.tags/1)
    |> Enum.filter(&unsafe_target_blank?/1)
    |> Enum.map(&issue_for(issue_meta, &1))
  end

  defp unsafe_target_blank?(%Heex.Tag{type: :tag, name: "a"} = tag) do
    static_target_blank?(tag) and not dynamic_attrs?(tag) and not safe_rel?(tag)
  end

  defp unsafe_target_blank?(_tag), do: false

  defp static_target_blank?(%Heex.Tag{} = tag) do
    tag
    |> attr_value("target")
    |> case do
      {:string, target, _meta} -> String.downcase(target) == "_blank"
      _other -> false
    end
  end

  defp dynamic_attrs?(%Heex.Tag{} = tag), do: Heex.has_attr?(tag, :root)

  defp safe_rel?(%Heex.Tag{} = tag) do
    tag
    |> attr_value("rel")
    |> case do
      {:string, rel, _meta} -> rel_has_token?(rel, "noopener")
      {:expr, _expr, _meta} -> true
      _other -> false
    end
  end

  defp rel_has_token?(rel, token) do
    rel
    |> String.split()
    |> Enum.any?(&(String.downcase(&1) == token))
  end

  defp attr_value(%Heex.Tag{attrs: attrs}, name) do
    attrs
    |> Enum.find(&(&1.name == name))
    |> case do
      %{value: value} -> value
      nil -> nil
    end
  end

  defp issue_for(issue_meta, %Heex.Tag{} = tag) do
    format_issue(
      issue_meta,
      message: @message,
      trigger: "<a",
      line_no: tag.line,
      column: tag.column
    )
  end
end