Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ex_icon.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
version: "1.8.0",
module_path: "output/lucide.ex",
module_name: MyAppWeb.Components.Lucide,
ignore_attrs: ["xmlns", "viewbox", "width", "height"]
attrs: ["stroke"]
]
]
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

## [0.2.0] - 2026-04-10

### Changed

- Replace `ignore_attrs` option with `attrs`.
- Add `aria-hidden="true"` to SVGs if not already present.

## [0.1.2] - 2026-04-10

### Fixed
Expand Down
57 changes: 53 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Add `ex_icon` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:ex_icon, "~> 0.1.2", only: :dev}
{:ex_icon, "~> 0.2.0", only: :dev}
]
end
```
Expand All @@ -39,10 +39,11 @@ ExIcon expects a configuration file named `.ex_icon.exs`.
module_path: "lib/my_app_web/components/lucide.ex",
# The name of the generated module.
module_name: MyAppWeb.Components.Lucide,
# SVG attributes that should _not_ be turned into component attributes.
# SVG attributes that should be turned into component attributes. Only
# attributes present in the original SVG files will be considered.
# Values must be lowercase strings.
# Default: ["xmlns", "viewbox", "width", "height"]
# ignore_attrs: []
# Example: ["stroke", "stroke-width"]
attrs: []
]
]
```
Expand All @@ -57,6 +58,54 @@ the icon library and generate a module with function components with:
mix ex_icon.gen_icons
```

## Attributes

ExIcon can optionally turn SVG attributes present in the original SVG files into
function component attributes.

For example, consider this original SVG:

```svg
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
```

If you set the `attrs` option to `["stroke"]`, the generated function component
will look like this:

```elixir
attr :stroke, :string, default: "currentColor"

def arrow_left(assigns) do
~H"""
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
stroke={@stroke}
stroke-width="2"
aria-hidden="true"
>
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
"""
end
```

Note that if you generate a lot of icons, compilation times can increase
substantially by adding attributes.

## Providers

Providers for specific icon libraries are based on the `ExIcon.Provider`
Expand Down
98 changes: 64 additions & 34 deletions lib/ex_icon.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ defmodule ExIcon do
"""

@default_config_path ".ex_icon.exs"
@default_ignore_attrs ["xmlns", "viewbox", "width", "height"]

@options_schema [
icons: [
Expand Down Expand Up @@ -42,14 +41,13 @@ defmodule ExIcon do
doc:
"The name of the generated module. Example: `MyApp.Components.Lucide`."
],
ignore_attrs: [
type: {:or, [{:list, :string}, {:in, [:all]}]},
attrs: [
type: {:list, :string},
required: false,
default: @default_ignore_attrs,
default: [],
doc: """
ExIcon substitutes all attributes of the `<svg>` element with HEEx
variables and adds the corresponding attributes to the HEEx
components. Attributes in this list are not substituted.
ExIcon substitutes all listed attributes of the `<svg>` element with HEEx
variables and adds the corresponding attributes to the HEEx components.
"""
]
]
Expand All @@ -67,13 +65,16 @@ defmodule ExIcon do
Takes an SVG as a string, extracts the attributes, and replaces the
attributes with HEEx variables.

The second argument is a list of attributes to ignore. It must be a list of
lowercase strings.
The second argument is a list of attributes to turn into component attributes.
It must be a list of lowercase strings. Attributes not present in the original
SVG file are ignored.

The function returns a tuple with the updated SVG string as the first element
and a list of substituted attributes with their values as a second element.
The attribute name is converted to snake case.

An `aria-hidden="true"` attribute is added if not already present.

## Example

iex> svg = \"\"\"
Expand All @@ -91,43 +92,80 @@ defmodule ExIcon do
...> \"\"\"
iex> ExIcon.transform_svg(svg)
{\"\"\"
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke={@stroke} stroke-width={@stroke_width}>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>\\
\"\"\", []}

iex> svg = \"\"\"
...> <svg
...> xmlns="http://www.w3.org/2000/svg"
...> width="24"
...> height="24"
...> viewBox="0 0 24 24"
...> stroke="currentColor"
...> stroke-width="2"
...> >
...> <path d="m12 19-7-7 7-7" />
...> <path d="M19 12H5" />
...> </svg>
...> \"\"\"
iex> ExIcon.transform_svg(svg, ["stroke", "stroke-width"])
{\"\"\"
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke={@stroke} stroke-width={@stroke_width} aria-hidden="true">
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>\\
\"\"\", [{"stroke", "currentColor"}, {"stroke_width", "2"}]}
"""
@spec transform_svg(svg, ignore_attrs) :: {svg, attrs}
when svg: binary, ignore_attrs: [binary], attrs: [{binary, binary}]
def transform_svg(svg, ignore_attrs \\ @default_ignore_attrs)
when is_binary(svg) and is_list(ignore_attrs) do
@spec transform_svg(svg, attrs) :: {svg, attrs}
when svg: binary, attrs: [binary], attrs: [{binary, binary}]
def transform_svg(svg, substitute_attrs \\ [])
when is_binary(svg) and is_list(substitute_attrs) do
case extract_svg(svg) do
{:ok, {attrs, inner}} ->
svg_attrs = build_attrs(attrs, ignore_attrs)
svg_attrs = build_attrs(attrs, substitute_attrs)

substituted_attrs =
attrs
|> Enum.reject(fn {k, _} -> ignore_attr?(k, ignore_attrs) end)
|> Enum.filter(fn {k, _} -> substitute_attr?(k, substitute_attrs) end)
|> Enum.map(fn {k, v} -> {to_snake_case(k), v} end)

svg = "<svg #{svg_attrs}>#{inner}</svg>"
svg = ~s(<svg #{svg_attrs} aria-hidden="true">#{inner}</svg>)
{svg, substituted_attrs}

:error ->
# original SVG has no attributes
svg =
svg
|> String.trim()
|> String.replace("<svg>", ~s(<svg aria-hidden="true">))

{svg, []}
end
end

defp ignore_attr?(attr, ignore_attrs) do
String.downcase(attr) in ignore_attrs
defp substitute_attr?(attr, substitute_attrs) do
String.downcase(attr) in substitute_attrs
end

defp build_attrs(attrs, ignore_attrs) do
Enum.map_join(attrs, " ", fn {k, v} ->
if ignore_attr?(k, ignore_attrs) do
~s(#{k}="#{v}")
else
defp build_attrs(attrs, []) do
attrs
|> Enum.reject(fn {k, _} -> k == "aria-hidden" end)
|> Enum.map_join(" ", fn {k, v} ->
~s(#{k}="#{v}")
end)
end

defp build_attrs(attrs, substitute_attrs) do
attrs
|> Enum.reject(fn {k, _} -> k == "aria-hidden" end)
|> Enum.map_join(" ", fn {k, v} ->
if substitute_attr?(k, substitute_attrs) do
"#{k}={@#{to_snake_case(k)}}"
else
~s(#{k}="#{v}")
end
end)
end
Expand All @@ -150,7 +188,7 @@ defmodule ExIcon do
@doc false
def prepare_assigns(path, opts) do
module_name = Keyword.fetch!(opts, :module_name)
ignore_attrs = Keyword.get(opts, :ignore_attrs, default_ignore_attrs())
substitute_attrs = Keyword.get(opts, :attrs, [])

icon_names =
case Keyword.fetch!(opts, :icons) do
Expand All @@ -162,7 +200,7 @@ defmodule ExIcon do
icon_names
|> Enum.map(fn icon_name ->
if svg = read_icon(path, icon_name) do
{to_snake_case(icon_name), transform_svg(svg, ignore_attrs)}
{to_snake_case(icon_name), transform_svg(svg, substitute_attrs)}
end
end)
|> Enum.reject(&is_nil/1)
Expand Down Expand Up @@ -258,14 +296,6 @@ defmodule ExIcon do
File.mkdir_p!(path)
end

@doc """
Returns the list of default ignore attributes for `ExIcon.transform_svg/1`.
"""
@spec default_ignore_attrs() :: [String.t()]
def default_ignore_attrs do
@default_ignore_attrs
end

@doc false
def indent(text, spaces) do
pad = String.duplicate(" ", spaces)
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ defmodule ExIcon.MixProject do
use Mix.Project

@source_url "https://github.com/woylie/ex_icon"
@version "0.1.2"
@version "0.2.0"

def project do
[
Expand Down
Loading
Loading