berrypod/lib/mix/tasks/generate_admin_icons.ex

127 lines
3.7 KiB
Elixir
Raw Normal View History

defmodule Mix.Tasks.GenerateAdminIcons do
@moduledoc """
Generates static CSS for heroicon classes used in admin templates.
Scans `lib/` for `hero-*` class references, reads the matching SVGs from
`deps/heroicons/optimized/`, and writes `assets/css/admin/icons.css` with
CSS mask-based icon classes that match the old Tailwind plugin output.
## Usage
mix generate_admin_icons
"""
@shortdoc "Generate admin/icons.css from heroicons SVGs"
use Mix.Task
@icons_dir "deps/heroicons/optimized"
@output_path "assets/css/admin/icons.css"
# Maps suffix to subdirectory and default size
@variants %{
"" => {"24/outline", "1.5rem"},
"-solid" => {"24/solid", "1.5rem"},
"-mini" => {"20/solid", "1.25rem"},
"-micro" => {"16/solid", "1rem"}
}
@impl Mix.Task
def run(_args) do
icons = scan_used_icons()
Mix.shell().info("Found #{length(icons)} icon references in lib/")
css_rules =
icons
|> Enum.sort()
|> Enum.map(&generate_rule/1)
|> Enum.reject(&is_nil/1)
missing = length(icons) - length(css_rules)
if missing > 0, do: Mix.shell().info("Skipped #{missing} (SVG not found or not an icon)")
content = """
/* Generated by mix generate_admin_icons do not edit by hand */
#{Enum.join(css_rules, "\n\n")}
"""
File.mkdir_p!(Path.dirname(@output_path))
File.write!(@output_path, content)
Mix.shell().info("Wrote #{length(css_rules)} icon rules to #{@output_path}")
end
defp scan_used_icons do
Path.wildcard("lib/**/*.{ex,heex}")
|> Enum.flat_map(fn path ->
path
|> File.read!()
|> then(&Regex.scan(~r/hero-[a-z][a-z0-9-]+/, &1))
|> List.flatten()
end)
|> Enum.uniq()
|> Enum.reject(&shop_class?/1)
end
# Filter out CSS class names from shop components that start with "hero-"
# but aren't actual heroicon references
defp shop_class?(name) do
name in [
"hero-section",
"hero-description",
"hero-cta",
"hero-cta-group",
"hero-pre-title",
"hero-error",
"hero-image",
"hero-section--page"
]
end
defp generate_rule(class_name) do
{suffix, {dir, size}} = detect_variant(class_name)
base = String.trim_leading(class_name, "hero-")
svg_name = if suffix == "", do: base, else: String.trim_trailing(base, suffix)
svg_path = Path.join([@icons_dir, dir, "#{svg_name}.svg"])
if File.exists?(svg_path) do
content =
svg_path
|> File.read!()
|> String.replace(~r/\r?\n|\r/, "")
|> URI.encode(&uri_allowed?/1)
"""
.#{class_name} {
--hero-#{class_name |> String.trim_leading("hero-")}: url('data:image/svg+xml;utf8,#{content}');
-webkit-mask: var(--hero-#{class_name |> String.trim_leading("hero-")});
mask: var(--hero-#{class_name |> String.trim_leading("hero-")});
mask-repeat: no-repeat;
background-color: currentColor;
vertical-align: middle;
display: inline-block;
width: #{size};
height: #{size};
}\
"""
else
Mix.shell().info(" warning: SVG not found for #{class_name} (expected #{svg_path})")
nil
end
end
defp detect_variant(class_name) do
cond do
String.ends_with?(class_name, "-micro") -> {"-micro", @variants["-micro"]}
String.ends_with?(class_name, "-mini") -> {"-mini", @variants["-mini"]}
String.ends_with?(class_name, "-solid") -> {"-solid", @variants["-solid"]}
true -> {"", @variants[""]}
end
end
# Characters that don't need percent-encoding in data URIs
defp uri_allowed?(char) do
URI.char_unreserved?(char) or char in [?<, ?>, ?/, ?=, ?", ?', ?:, ?;, ?(, ?), ?!, ?@, ?,, ?.]
end
end