defmodule GitGud.Badges.Spec do
@moduledoc """
What a badge URL asks for, as path segments rather than a query string:
/r/:owner/:name/badge/ci.svg
/r/:owner/:name/badge/ci/dot/2.5x/gg/forest.png
/r/:owner/:name/badge/coverage/outline/square/light.svg
/r/:owner/:name/badge/workflow/audit/dot.svg
/r/:owner/:name/badge/package/cargo/gitgud-cli/2x.png
The first segment is the kind. `workflow` takes the workflow's name as
the next one (its `name:` or its file's basename), and `package` the
registry format and the package's name as the next two. The rest are
options in any order,
each at most once, with the format on the last segment. Anything left
out takes its default, and `to_path/1` writes them back in one
canonical order with the defaults dropped, so a badge has one URL.
The badge sheet (`/r/:owner/:name/badges/…`) takes the same option
segments minus kind, theme and format, so the sheet's URL and the
images it previews say the same thing.
"""
alias GitGud.Packages.Format
@kinds ~w(ci ci-duration last-commit coverage workflow package issues pulls commits
contributors release license mirror language syms mark)
@named_kinds ~w(workflow package)
@styles ~w(split dot outline)
@corners ~w(round square)
@scales %{"1x" => 1.0, "2x" => 2.0, "2.5x" => 2.5}
@scale_order ~w(1x 2x 2.5x)
@formats ~w(svg png)
@type t :: %__MODULE__{
kind: String.t(),
name: String.t() | nil,
registry: String.t() | nil,
style: String.t(),
corners: String.t(),
scale: String.t(),
gg: boolean(),
theme: String.t(),
format: String.t()
}
defstruct kind: "ci",
name: nil,
registry: nil,
style: "split",
corners: "round",
scale: "1x",
gg: false,
theme: "auto",
format: "svg"
def kinds, do: @kinds
def styles, do: @styles
def corners, do: @corners
def scales, do: @scale_order
def formats, do: @formats
def themes, do: ["auto" | GitGud.Themes.all()]
@doc "The numeric zoom for a scale token: `\"2.5x\"` is `2.5`."
def zoom(%__MODULE__{scale: scale}), do: Map.fetch!(@scales, scale)
@doc """
Parse a badge image path's segments, e.g.
`["ci", "dot", "2.5x", "forest.png"]`.
"""
@spec parse_path([String.t()]) :: {:ok, t()} | :error
def parse_path([]), do: :error
def parse_path(segments) do
{init, [last]} = Enum.split(segments, -1)
ext = Path.extname(last)
format = String.trim_leading(ext, ".")
with true <- format in @formats,
{:ok, spec, tokens} <- take_kind(init ++ [Path.rootname(last, ext)]),
{:ok, spec} <- apply_tokens(%{spec | format: format}, tokens, true) do
{:ok, spec}
else
_ -> :error
end
end
defp take_kind(["workflow", name | tokens]) do
if valid_name?(name),
do: {:ok, %__MODULE__{kind: "workflow", name: name}, tokens},
else: :error
end
defp take_kind(["package", registry, name | tokens]) do
if registry in Format.formats() and valid_name?(name),
do: {:ok, %__MODULE__{kind: "package", registry: registry, name: name}, tokens},
else: :error
end
defp take_kind([kind | tokens]) when kind in @kinds and kind not in @named_kinds,
do: {:ok, %__MODULE__{kind: kind}, tokens}
defp take_kind(_), do: :error
@doc "Whether `name` can name a workflow in a badge path."
def valid_name?(name),
do:
is_binary(name) and String.valid?(name) and byte_size(name) in 1..100 and
name =~ ~r/^[^\x00-\x1f]+$/
@doc """
Parse the badge sheet's option segments (style, corners, scale, `gg`).
"""
@spec parse_options([String.t()]) :: {:ok, t()} | :error
def parse_options(segments), do: apply_tokens(%__MODULE__{}, segments, false)
defp apply_tokens(spec, tokens, theme?) do
Enum.reduce_while(tokens, {:ok, spec, MapSet.new()}, fn token, {:ok, spec, seen} ->
case classify(token, theme?) do
{field, value} ->
if MapSet.member?(seen, field),
do: {:halt, :error},
else: {:cont, {:ok, Map.put(spec, field, value), MapSet.put(seen, field)}}
nil ->
{:halt, :error}
end
end)
|> case do
{:ok, spec, _seen} -> {:ok, spec}
:error -> :error
end
end
defp classify(token, theme?) do
cond do
token in @styles -> {:style, token}
token in @corners -> {:corners, token}
Map.has_key?(@scales, token) -> {:scale, token}
token == "gg" -> {:gg, true}
theme? and token in themes() -> {:theme, token}
true -> nil
end
end
@doc """
The canonical image path below `/r/:owner/:name/badge/`, defaults
dropped: `ci.svg`, `ci/dot/2.5x/gg/forest.png`.
"""
@spec to_path(t()) :: String.t()
def to_path(%__MODULE__{} = spec) do
theme = if spec.theme == "auto", do: [], else: [spec.theme]
(kind_segments(spec) ++ option_tokens(spec) ++ theme)
|> Enum.join("/")
|> Kernel.<>(".#{spec.format}")
end
@doc ~s(The kind's own segments: `["ci"]`, `["workflow", "audit"]`, `["package", "cargo", "x"]`.)
@spec kind_segments(t()) :: [String.t()]
def kind_segments(%__MODULE__{kind: "workflow", name: name}), do: ["workflow", name]
def kind_segments(%__MODULE__{kind: "package", registry: registry, name: name}),
do: ["package", registry, name]
def kind_segments(%__MODULE__{kind: kind}), do: [kind]
@doc ~s(The badge sheet's option segments, `["dot", "2.5x", "gg"]`, defaults dropped.)
@spec option_segments(t()) :: [String.t()]
def option_segments(%__MODULE__{} = spec), do: option_tokens(spec)
@doc """
The segments after the kind with the theme always written out, even
`auto`: `[\"dot\", \"auto.svg\"]`. For URLs, like the sheet's samples,
that need at least one segment there.
"""
@spec explicit_segments(t()) :: [String.t()]
def explicit_segments(%__MODULE__{} = spec),
do: option_tokens(spec) ++ ["#{spec.theme}.#{spec.format}"]
defp option_tokens(spec) do
default = %__MODULE__{}
[
spec.style != default.style && spec.style,
spec.corners != default.corners && spec.corners,
spec.scale != default.scale && spec.scale,
spec.gg && "gg"
]
|> Enum.filter(& &1)
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
6.2 KiB · text
History
0d6bdf0