Packages

A library for rendering inline svgs with Phoenix, Phoenix LiveView, and Surface.

Current section

Files

Jump to
svg_icons lib svg_icons.ex
Raw

lib/svg_icons.ex

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)}")
["<span>", "Failed to load icon ", inspect(id), "</span>"]
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))
"<svg" <> contents =
path
|> File.read!()
|> String.replace("\n", "")
|> String.trim()
{id, ["<svg", contents]}
end
end
def collect_pattern_parts(path_parts) do
Enum.map(path_parts, fn
path when is_binary(path) ->
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