lib/openagents_web/icons.ex

58e6347eeb72 · 3 KB

defmodule OpenAgentsWeb.Icons do
  @moduledoc """
  The vendored icon set, embedded at compile time.

  Files under `priv/icons` are produced from
  [openai/apps-sdk-ui](https://github.com/openai/apps-sdk-ui) and are never
  hand-edited. See `priv/icons/README.md` and `priv/icons/LICENSE`.

  Each glyph is stored as a complete standalone SVG so the file stays viewable
  and diffable on its own. This module splits it into the viewBox and the inner
  markup so `OpenAgentsWeb.UI.icon/1` can render a fresh root element
  with the accessibility and sizing attributes the call site needs.
  """

  @icons_dir Path.join([__DIR__, "..", "..", "priv", "icons"]) |> Path.expand()

  # Brand marks live apart from the vendored set: a logo can never come from a
  # generic icon library. They are namespaced `brand-*`. See priv/brand/README.md.
  @brand_dir Path.join([__DIR__, "..", "..", "priv", "brand"]) |> Path.expand()

  # Issue-state glyphs from Primer Octicons. See priv/octicons/README.md.
  @octicons_dir Path.join([__DIR__, "..", "..", "priv", "octicons"]) |> Path.expand()

  @paths (@icons_dir |> Path.join("*.svg") |> Path.wildcard()) ++
           (@brand_dir |> Path.join("*.svg") |> Path.wildcard()) ++
           (@octicons_dir |> Path.join("*.svg") |> Path.wildcard())

  for path <- @paths do
    @external_resource path
  end

  @icons Map.new(@paths, fn path ->
           base = Path.basename(path, ".svg")

           name =
             cond do
               Path.dirname(path) == @brand_dir -> "brand-" <> base
               Path.dirname(path) == @octicons_dir -> "octicon-" <> base
               true -> base
             end

           contents = path |> File.read!() |> String.trim()

           view_box =
             case Regex.run(~r/viewBox="([^"]+)"/, contents) do
               [_, value] -> value
               _ -> raise "#{path} has no viewBox"
             end

           inner =
             case Regex.run(~r|<svg\b[^>]*>(.*)</svg>|s, contents) do
               [_, markup] -> String.trim(markup)
               _ -> raise "#{path} is not a single <svg> element"
             end

           {name, {view_box, inner}}
         end)

  if map_size(@icons) == 0 do
    raise "no icons found in #{@icons_dir}. Run: mix openagents.icons.vendor <path-to-apps-sdk-ui>"
  end

  @names @icons |> Map.keys() |> Enum.sort()

  @doc "Every vendored icon name, sorted."
  def names, do: @names

  @doc "How many glyphs are vendored."
  def count, do: length(@names)

  @doc "Whether a glyph exists."
  def exists?(name) when is_binary(name), do: Map.has_key?(@icons, name)

  @doc """
  The `{view_box, inner_markup}` for one glyph.

  Raises on an unknown name rather than rendering an empty box, so a typo is a
  visible failure instead of a silently missing affordance.
  """
  def fetch!(name) when is_binary(name) do
    case Map.fetch(@icons, name) do
      {:ok, icon} ->
        icon

      :error ->
        raise ArgumentError, """
        unknown icon #{inspect(name)}

        #{count()} glyphs are vendored in priv/icons. #{suggestion(name)}

        See OpenAgentsWeb.Icons.names/0 to list them.
        """
    end
  end

  defp suggestion(name) do
    stem = name |> String.split("-") |> List.first()

    case Enum.filter(@names, &String.starts_with?(&1, stem)) do
      [] -> "Run `OpenAgentsWeb.Icons.names/0` to list them."
      near -> "Did you mean: #{near |> Enum.take(8) |> Enum.join(", ")}?"
    end
  end
end