defmodule SvgIcons do @moduledoc """ SvgIcons creates helpers to inject inline svgs into your Phoenix, Phoenix LiveView, and Surface templates without having to manually manage the svgs. It was originally created to help with the heroicon set that is released and maintained by the tailwind developers (and for use with tailwind ui); however, I've found it's useful for other cases as well. ## Usage To use SvgIcons, create a module and simple `use SvgIcons`. The following options are supported: * `:path` - (required) the path expression to find svg files * `:path_sep` - the path separator to use (defaults to `/`) * `:ext` - the file extension (defaults to `.svg`) * `:base_dir` - the base directory to resolve path from (defaults to the directory the module is located in) * `:surface` - should a surface MacroComponent be generated in addition to the svg utility function? (defaults to `true`) ### Path expression This is how you specify the location and identification properties for the svgs in an icon set. A path expression is a list composed of literal strings, atoms, and tuples. Literal strings are interpreted as literal path segments, an atom the name of a single path segment, and tuples allow you to specify the name, allowed values, and the default value for a path segment. For example: * "dist/optimized" is a treated as a static path segment * `:variant` is treated as a variable path segment named `variant` * `{:variant, "outline"}` is treated as a variable path segment named `variant` and default value "outline" * `{:variant, [:outline, :solid]}` is treated as a variable path segment named `variant` that allows the values `outline` and `solid` * `{:vairant, [:outline, :solid], "outline"}` is treated as a variable path segment named `variant` that allows either `outline` or `solid` and defaults to `outline` Putting that together, `["dist/optimized", {:group, "all"}, {:variant, [:outline, :solid]}, :icon]` specifies the following path: `dist/optimized/\#{group || "all"}/\#{variant}/\#{icon}.svg`, with `variant` limited to either `outline` or `solid`. The module enumerates all potential paths for files that match, reads them in, and tells elixir that they are external resources (ensuring the module will be recompiled if any of them change). Each file should be a single svg element. There are two ways to use the module. If you have surface in your dependencies, it will turn the module into a MacroComponent where each of the variables from the path expression are exposed as props along with `class` and `opts` where class is treated as the class attribute, and opts are treated as a keyword list of attribute name, attribute value pairs that are injected into the svg. ## Examples ### Hero Icons $ git submodule init https://github.com/tailwindlabs/heroicons svgs/heroicons defmodule HeroIcon do use SvgIcons, path: ["svgs/heroicons/optimized", {:variant, [:outline, :solid], "outline"}, :icon] end defmodule Component do use Surface.Component def render(assigns) do ~F"\"" <#HeroIcon variant="solid" icon="dots-vertical" class="w-5 h-5" /> "\"" end end defmodule PhoenixComponent do use Phoenix.LiveComponent def render(assigns) do ~L"\"" <%= HeroIcon.svg({"solid", "dots-vertical"}, class: "w-5 h-5") "\"" end end ### Remix Icons $ git submodule init https://github.com/Remix-Design/RemixIcon svgs/remixicon defmodule RemixIcon do use SvgIcons, path: ["svgs/remixicon", :group, :icon] end defmodule Component do use Surface.Component def render(assigns) do ~F"\"" <#RemixIcon group="Editor" icon="bold" class="w-5 h-5 text-current" /> "\"" end end defmodule PhoenixComponent do use Phoenix.LiveComponent def render(assigns) do ~L"\"" <%= RemixIcon.svg({"Editor", "bold"}, class: "w-5 h-5 text-current") "\"" end end """ defmacro __using__(opts) do path_parts = Keyword.get(opts, :path) extension = Keyword.get(opts, :ext, ".svg") path_sep = Keyword.get(opts, :path_sep, "/") base_dir = Keyword.get(opts, :base_dir, Path.dirname(__CALLER__.file)) include_surface = Keyword.get(opts, :surface, true) quote do @path_pattern SvgIcons.collect_pattern_parts(unquote(path_parts)) @svgs SvgIcons.read_svgs( unquote(base_dir), @path_pattern, unquote(extension), unquote(path_sep) ) defp svgs(), do: @svgs with {:module, _} <- Code.ensure_compiled(Surface) do unquote(if include_surface, do: define_surface_macro(__CALLER__)) end def svg(id, attrs \\ []) do Phoenix.HTML.raw(render_svg(id, attrs)) end def render_svg(id, attrs), do: SvgIcons.render_svg(svgs(), id, attrs) end end def render_svg(svgs, id, attrs) do if Map.has_key?(svgs, id) do [head, tail] = Map.get(svgs, id) [head, translate_attrs(attrs), tail] else IO.warn("Could not find icon for #{inspect(id)}") ["", "Failed to load icon ", inspect(id), ""] end end def define_surface_macro(caller) do quote do use Surface.MacroComponent for {name, _, _, default} <- @path_pattern do Surface.API.put_assign( __ENV__, :prop, name, :string, [default: default, static: true], [default: default, static: true], unquote(caller.line) ) end prop(id, :string, static: true) prop(class, :string, static: true) prop(opts, :keyword, default: [], static: true) def expand(attributes, _children, meta), do: SvgIcons.expand(__MODULE__, svgs(), attributes, meta, @path_pattern) end end defmacro read_svgs(base_dir, pattern_parts, extension, path_sep) do quote do SvgIcons.read_svgs( __MODULE__, unquote(base_dir), unquote(pattern_parts), unquote(extension), unquote(path_sep) ) end end def expand(module, svgs, attributes, meta, path_pattern) do with {:module, _module} <- Code.ensure_compiled(Surface.MacroComponent) do props = Surface.MacroComponent.eval_static_props!(module, attributes, meta.caller) defaults = for {name, _, _, default} <- path_pattern, into: %{} do {name, default} end capture_names = path_pattern |> Enum.map(fn {name, _, _, _} -> name end) |> Enum.reject(&is_nil/1) id = capture_names |> Enum.map(fn name -> props[name] || Map.get(defaults, name) end) |> List.to_tuple() class = props[:class] || "" opts = props[:opts] || [] attrs = opts ++ [class: class] ++ Enum.map(capture_names, fn name -> {"data-#{to_string(name)}", props[name]} end) struct!(Surface.AST.Literal, value: render_svg(svgs, id, attrs) |> IO.iodata_to_binary()) end end def read_svgs(module, base_dir, pattern_parts, extension, path_sep) do regex = ((pattern_parts |> Enum.map(fn {_, pattern, _, _} -> pattern end) |> Enum.join(path_sep)) <> Regex.escape(extension)) |> Regex.compile!() capture_names = pattern_parts |> Enum.map(fn {name, _, _, _} -> name end) |> Enum.reject(&is_nil/1) capture_name_strings = Enum.map(capture_names, &to_string/1) files = ((pattern_parts |> Enum.map(fn {_, _, wildcard, _} -> wildcard end) |> Enum.join(path_sep)) <> extension) |> Path.expand(base_dir) |> Path.wildcard() |> Enum.sort() for path <- files, relative_path = Path.relative_to(path, base_dir), captures = Regex.named_captures(regex, relative_path), captures != nil, id = capture_name_strings |> Enum.map(fn name -> captures[name] end) |> List.to_tuple(), into: %{} do Module.put_attribute(module, :external_resource, Path.relative_to_cwd(path)) " contents = path |> File.read!() |> String.replace("\n", "") |> String.trim() {id, [" path_segment(path) name when is_atom(name) -> named_path_segment(name) {name, default} when is_atom(name) and is_binary(default) -> named_path_segment(name, default) {name, options} when is_atom(name) and is_list(options) -> enum_path_segment(name, options) {name, options, default} when is_atom(name) and is_list(options) and is_binary(default) -> enum_path_segment(name, options, default) end) end defp path_segment(path), do: {nil, Regex.escape(path), path, nil} defp named_path_segment(name, default \\ nil), do: {name, "(?<#{to_string(name)}>[^/]+)", "*", default} defp enum_path_segment(name, values, default \\ nil) do regex_or = values |> Enum.map(&to_string/1) |> Enum.map(&Regex.escape/1) |> Enum.join("|") wildcard_or = values |> Enum.map(&to_string/1) |> Enum.join(",") {name, "(?<#{to_string(name)}>#{regex_or})", "{#{wildcard_or}}", default} end defp translate_attrs([]) do [] end defp translate_attrs([{key, true} | tail]) do [" ", to_string(key), translate_attrs(tail)] end defp translate_attrs([{_, value} | tail]) when is_nil(value) or value == false do translate_attrs(tail) end defp translate_attrs([{key, value} | tail]) do [" ", to_string(key), ~S(="), value, ~S("), translate_attrs(tail)] end end