9.7 KiB · text History b868907
defmodule GitGudWeb.BadgeCatalog do
  @moduledoc """
  Every badge a repository has, and how to embed each one: the shared
  answer behind the badge sheet's "This repository now"
  (`GitGudWeb.RepoLive.Badges`) and the API that `gg badges` reads
  (`GitGudWeb.Api.Mobile.BadgeController`), so the page and the CLI
  never disagree about what a README block says.

  A badge is named by a **key**: the kind for most (`ci`, `release`),
  `workflow:<name>` for a workflow, `package:<format>:<name>` for a
  package. `embed/3` turns a key plus the display options into its
  image URLs, link, alt text and the Markdown and HTML that show it.
  """
  use GitGudWeb, :verified_routes

  alias GitGud.Badges
  alias GitGud.Badges.Spec
  alias GitGud.Repositories.Repository
  alias GitGud.Repositories.Storage

  # The plain kinds, in display order. Workflows and packages come from
  # the repository.
  @kinds ~w(ci ci-duration last-commit coverage release license language syms issues pulls
             commits contributors mirror)

  # What a README block starts with, of the badges the repository has.
  @readme_defaults ~w(ci coverage release license)

  @groups [
    {"status", "Status"},
    {"activity", "Activity"},
    {"project", "Project"},
    {"mark", "Mark"}
  ]

  @kind_groups %{
    "ci" => "status",
    "ci-duration" => "status",
    "coverage" => "status",
    "workflow" => "status",
    "last-commit" => "activity",
    "commits" => "activity",
    "contributors" => "activity",
    "issues" => "activity",
    "pulls" => "activity",
    "release" => "project",
    "license" => "project",
    "language" => "project",
    "syms" => "project",
    "mirror" => "project",
    "package" => "project",
    "mark" => "mark"
  }

  @kind_labels %{
    "ci" => "CI",
    "last-commit" => "Last commit",
    "coverage" => "Coverage",
    "workflow" => "Workflow",
    "package" => "Package",
    "issues" => "Issues",
    "pulls" => "Pulls",
    "commits" => "Commits",
    "contributors" => "Contributors",
    "ci-duration" => "CI duration",
    "release" => "Release",
    "license" => "License",
    "mirror" => "Mirror",
    "language" => "Language",
    "syms" => "Syms",
    "mark" => "Mark"
  }

  def kinds, do: @kinds
  def groups, do: @groups
  def kind_labels, do: @kind_labels
  def readme_defaults, do: @readme_defaults

  @typedoc "One badge the repository has, with what it says right now."
  @type item :: %{
          key: String.t(),
          kind: String.t(),
          title: String.t(),
          hint: String.t(),
          group: String.t(),
          content: GitGud.Badges.Svg.content(),
          index: non_neg_integer()
        }

  @doc """
  Every badge `repo` has, grouped (status, activity, project, mark) and
  in display order within each group. A mirror badge only for a
  repository that follows an upstream (`Badges.mirror?/1`); a workflow badge for each
  workflow that has run on the default branch; a package badge for each
  package.
  """
  @spec items(Repository.t()) :: [item()]
  def items(%Repository{} = repo) do
    branch = repo.default_branch || "main"

    plain =
      for kind <- plain_kinds(repo) do
        item(kind, Badges.content(repo, kind), @kind_labels[kind], hint(kind, branch))
      end

    workflows =
      for name <- Badges.workflow_names(repo) do
        item("workflow:" <> name, Badges.workflow(repo, name), name, "Newest run on #{branch}")
      end

    packages =
      for {registry, name} <- Badges.packages(repo) do
        item(
          "package:#{registry}:#{name}",
          Badges.package(repo, registry, name),
          name,
          "#{registry} package"
        )
      end

    mark = [item("mark", :mark, "GG", "The mark on its own")]

    (plain ++ workflows ++ packages ++ mark)
    |> Enum.with_index()
    |> Enum.map(fn {item, i} -> Map.put(item, :index, i) end)
    |> Enum.sort_by(&group_rank(&1.group))
  end

  # A mirror badge only for a repository that really follows an upstream,
  # not one imported once and developed here since (`Badges.mirror?/1`).
  defp plain_kinds(repo), do: if(Badges.mirror?(repo), do: @kinds, else: @kinds -- ["mirror"])

  defp item(key, content, title, hint) do
    {kind, _name, _registry} = split_key(key)

    %{
      key: key,
      kind: kind,
      content: content,
      title: title,
      hint: hint,
      group: Map.fetch!(@kind_groups, kind)
    }
  end

  defp group_rank(group), do: Enum.find_index(@groups, &(elem(&1, 0) == group))

  defp hint("ci", branch), do: "Newest push to #{branch}"
  defp hint("ci-duration", _), do: "How long that push's CI took"
  defp hint("coverage", _), do: "From gitgud-coverage"
  defp hint("last-commit", branch), do: "Age of #{branch}'s head"
  defp hint("commits", branch), do: "History of #{branch}"
  defp hint("contributors", _), do: "Everyone who authored a commit"
  defp hint("issues", _), do: "Open issues"
  defp hint("pulls", _), do: "Open pull requests"
  defp hint("release", _), do: "Newest tag"
  defp hint("license", _), do: "Detected from the license file"
  defp hint("language", _), do: "Largest share of the code"
  defp hint("syms", _), do: "What people marked it with"
  defp hint("mirror", _), do: "Since the last upstream sync"

  @doc "`{kind, name, registry}` for a key."
  @spec split_key(String.t()) :: {String.t(), String.t() | nil, String.t() | nil}
  def split_key("workflow:" <> name), do: {"workflow", name, nil}

  def split_key("package:" <> rest) do
    case String.split(rest, ":", parts: 2) do
      [registry, name] -> {"package", name, registry}
      _ -> {"package", rest, nil}
    end
  end

  def split_key(kind), do: {kind, nil, nil}

  @doc "The badge `key` names, drawn with `options`' style, corners, scale and GG."
  @spec spec(Spec.t(), String.t(), String.t(), String.t()) :: Spec.t()
  def spec(%Spec{} = options, key, theme, format) do
    {kind, name, registry} = split_key(key)
    %{options | kind: kind, name: name, registry: registry, theme: theme, format: format}
  end

  @typedoc "Everything one badge needs to be embedded."
  @type embed :: %{
          path: String.t(),
          url: String.t(),
          dark_url: String.t(),
          light_url: String.t(),
          link: String.t(),
          alt: String.t(),
          markdown: String.t(),
          html: String.t()
        }

  @doc """
  The URLs, link, alt text, Markdown and HTML for the badge `spec`
  describes. The HTML picks the `dark` or `light` image by the reader's
  setting, for formats (PNG) and sites that can't follow it themselves.
  """
  @spec embed(Repository.t(), String.t(), Spec.t()) :: embed()
  def embed(%Repository{} = repo, handle, %Spec{} = spec) do
    path = image_path(handle, repo, spec)
    url = absolute(path)
    dark = absolute(image_path(handle, repo, %{spec | theme: GitGud.Themes.system_dark()}))
    light = absolute(image_path(handle, repo, %{spec | theme: GitGud.Themes.system_light()}))
    link = absolute(link_path(handle, repo, spec.kind))
    alt = alt(spec)

    %{
      path: path,
      url: url,
      dark_url: dark,
      light_url: light,
      link: link,
      alt: alt,
      markdown: "[![#{alt}](#{url})](#{link})",
      html: """
      <a href="#{link}">
        <picture>
          <source media="(prefers-color-scheme: dark)" srcset="#{dark}">
          <img alt="#{alt}" src="#{light}">
        </picture>
      </a>\
      """
    }
  end

  @doc "The repository's handle as its URLs spell it."
  def handle(%Repository{} = repo), do: Storage.repo_handle(repo)

  @doc "The image path below the app's root for `spec`."
  def image_path(handle, repo, %Spec{} = spec) do
    segments = spec |> Spec.to_path() |> String.split("/")
    ~p"/r/#{handle}/#{repo.name}/badge/#{segments}"
  end

  @doc "Where a badge of `kind` links to."
  def link_path(handle, repo, kind) when kind in ["ci", "coverage", "workflow", "ci-duration"],
    do: ~p"/r/#{handle}/#{repo.name}/actions"

  def link_path(handle, repo, "issues"), do: ~p"/r/#{handle}/#{repo.name}/issues"
  def link_path(handle, repo, "pulls"), do: ~p"/r/#{handle}/#{repo.name}/pulls"
  def link_path(handle, repo, "package"), do: ~p"/r/#{handle}/#{repo.name}/packages"
  def link_path(handle, repo, "language"), do: ~p"/r/#{handle}/#{repo.name}/stats/languages"

  def link_path(handle, repo, "contributors"),
    do: ~p"/r/#{handle}/#{repo.name}/stats/committers"

  def link_path(handle, %{license_file: file} = repo, "license") when is_binary(file),
    do:
      ~p"/r/#{handle}/#{repo.name}/blob/#{repo.default_branch || "main"}/#{String.split(file, "/")}"

  def link_path(handle, repo, kind) when kind in ["last-commit", "commits"],
    do: ~p"/r/#{handle}/#{repo.name}/commits/#{repo.default_branch || "main"}"

  def link_path(handle, repo, _kind), do: ~p"/r/#{handle}/#{repo.name}"

  defp absolute(path), do: GitGudWeb.Endpoint.url() <> path

  @doc "The image's alt text."
  def alt(%Spec{kind: "ci"}), do: "CI status"
  def alt(%Spec{kind: "last-commit"}), do: "last commit"
  def alt(%Spec{kind: "coverage"}), do: "coverage"
  def alt(%Spec{kind: "workflow", name: name}), do: "#{name} status"
  # The format too: a package published as both deb and rpm has a badge
  # for each, and they shouldn't read the same.
  def alt(%Spec{kind: "package", name: name, registry: registry}),
    do: "install #{name} (#{registry})"

  def alt(%Spec{kind: "issues"}), do: "open issues"
  def alt(%Spec{kind: "pulls"}), do: "open pull requests"
  def alt(%Spec{kind: "commits"}), do: "commits"
  def alt(%Spec{kind: "contributors"}), do: "contributors"
  def alt(%Spec{kind: "ci-duration"}), do: "CI duration"
  def alt(%Spec{kind: "release"}), do: "latest release"
  def alt(%Spec{kind: "license"}), do: "license"
  def alt(%Spec{kind: "mirror"}), do: "mirror sync"
  def alt(%Spec{kind: "language"}), do: "top language"
  def alt(%Spec{kind: "syms"}), do: "syms"
  def alt(%Spec{kind: "mark"}), do: "GitGud"
end