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 */ @layer admin { #{Enum.join(css_rules, "\n\n")} } /* @layer admin */ """ 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