Skip to content

Commit 5b050d4

Browse files
committed
Initial commit: Phoenix integration for Iconify icons
0 parents  commit 5b050d4

17 files changed

Lines changed: 1524 additions & 0 deletions

.formatter.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[
2+
import_deps: [:phoenix_live_view],
3+
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4+
]

.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/_build/
2+
/cover/
3+
/deps/
4+
/doc/
5+
/.fetch
6+
erl_crash.dump
7+
*.ez
8+
phoenix_iconify-*.tar
9+
/tmp/
10+
.elixir_ls/
11+
/priv/iconify/manifest.etf
12+
/priv/iconify/sets/

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Changelog
2+
3+
## v0.1.0
4+
5+
- Initial release
6+
- Phoenix component for rendering icons
7+
- Compile-time icon discovery from `__components_calls__`
8+
- Automatic icon fetching from Iconify API
9+
- Manifest caching in priv/iconify/

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# PhoenixIconify
2+
3+
Phoenix components for [Iconify](https://iconify.design) icons with compile-time discovery.
4+
5+
Access 200,000+ icons from 150+ icon sets. Browse available icons at [icon-sets.iconify.design](https://icon-sets.iconify.design).
6+
7+
## Features
8+
9+
- **Compile-time discovery** - Icons are automatically detected from your templates
10+
- **On-demand fetching** - Only icons you use are downloaded
11+
- **Zero runtime overhead** - Icons are embedded at compile time
12+
- **LiveView optimized** - Minimal diffs, only attributes change
13+
14+
## Installation
15+
16+
Add `phoenix_iconify` to your list of dependencies in `mix.exs`:
17+
18+
```elixir
19+
def deps do
20+
[
21+
{:phoenix_iconify, "~> 0.1.0"}
22+
]
23+
end
24+
```
25+
26+
Add the compiler to your project:
27+
28+
```elixir
29+
def project do
30+
[
31+
compilers: Mix.compilers() ++ [:phoenix_iconify],
32+
# ...
33+
]
34+
end
35+
```
36+
37+
## Usage
38+
39+
Import the component in your web module (`lib/my_app_web.ex`):
40+
41+
```elixir
42+
defp html_helpers do
43+
quote do
44+
import PhoenixIconify, only: [icon: 1]
45+
# ...
46+
end
47+
end
48+
```
49+
50+
Use icons in your templates:
51+
52+
```heex
53+
<.icon name="heroicons:user" />
54+
<.icon name="heroicons:user" class="w-6 h-6 text-blue-500" />
55+
<.icon name="lucide:home" id="home-icon" />
56+
```
57+
58+
## How It Works
59+
60+
1. You use `<.icon name="heroicons:user" />` in your templates
61+
2. During compilation, the compiler scans for icon component calls
62+
3. It extracts literal icon names from the `name` attribute
63+
4. Missing icons are fetched from the Iconify API
64+
5. Icons are cached in `priv/iconify/manifest.etf`
65+
6. At runtime, icons are loaded from the manifest
66+
67+
## Icon Names
68+
69+
Icons use the format `prefix:icon-name`:
70+
71+
- `heroicons:user` - Heroicons user icon
72+
- `heroicons:user-solid` - Heroicons solid user
73+
- `lucide:home` - Lucide home icon
74+
- `mdi:account` - Material Design Icons account
75+
76+
Browse all icons at [icon-sets.iconify.design](https://icon-sets.iconify.design).
77+
78+
## Configuration
79+
80+
```elixir
81+
# config/config.exs
82+
config :phoenix_iconify,
83+
# Pre-register icons for dynamic usage (e.g., icons from database)
84+
extra_icons: ["heroicons:check", "heroicons:x-mark"],
85+
86+
# Fallback icon when requested icon is not found
87+
fallback: "heroicons:question-mark-circle",
88+
89+
# Log warnings when icons are not found (default: true)
90+
warn_on_missing: true
91+
```
92+
93+
## Caching
94+
95+
Icon sets are cached locally in `priv/iconify/sets/` to avoid repeated downloads.
96+
97+
```bash
98+
# Pre-fetch icon sets for faster subsequent compiles
99+
mix phoenix_iconify.cache fetch
100+
101+
# List cached sets
102+
mix phoenix_iconify.cache list
103+
104+
# Clear cache
105+
mix phoenix_iconify.cache clear
106+
107+
# Show statistics
108+
mix phoenix_iconify.stats
109+
```
110+
111+
## Dynamic Icons
112+
113+
For icons that can't be discovered at compile time (e.g., from database):
114+
115+
```elixir
116+
config :phoenix_iconify,
117+
extra_icons: [
118+
"heroicons:check",
119+
"heroicons:x-mark",
120+
"heroicons:exclamation-triangle"
121+
]
122+
```
123+
124+
## Mix Tasks
125+
126+
```bash
127+
mix phoenix_iconify # Show help
128+
mix phoenix_iconify.stats # Show statistics
129+
mix phoenix_iconify.list # List icons in manifest
130+
mix phoenix_iconify.cache # Cache management
131+
```
132+
133+
## License
134+
135+
MIT
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
defmodule Mix.Tasks.Compile.PhoenixIconify do
2+
@moduledoc """
3+
Compiles icon assets by discovering icons used in templates.
4+
5+
This compiler:
6+
1. Scans compiled modules for icon component calls
7+
2. Extracts literal icon names from the `name` attribute
8+
3. Fetches missing icons from Iconify
9+
4. Updates the manifest in priv/iconify/
10+
11+
## Usage
12+
13+
Add to your mix.exs:
14+
15+
def project do
16+
[
17+
compilers: Mix.compilers() ++ [:phoenix_iconify],
18+
# ...
19+
]
20+
end
21+
22+
"""
23+
24+
use Mix.Task.Compiler
25+
26+
alias PhoenixIconify.{Cache, Manifest, Scanner}
27+
28+
@recursive true
29+
30+
@impl true
31+
def run(_args) do
32+
# Ensure Finch is started for HTTP requests
33+
{:ok, _} = Application.ensure_all_started(:req)
34+
35+
# Scan source files for icon usage
36+
scanned_icons = Scanner.scan()
37+
38+
# Add extra icons from config (for dynamic usage)
39+
extra_icons =
40+
Application.get_env(:phoenix_iconify, :extra_icons, [])
41+
|> Enum.map(&PhoenixIconify.normalize_name/1)
42+
|> Enum.reject(&is_nil/1)
43+
44+
icons = Enum.uniq(scanned_icons ++ extra_icons) |> Enum.sort()
45+
46+
if icons == [] do
47+
{:ok, []}
48+
else
49+
process_icons(icons)
50+
end
51+
end
52+
53+
defp process_icons(icon_names) do
54+
# Validate icon names
55+
{valid, invalid} =
56+
Enum.split_with(icon_names, fn name ->
57+
case Iconify.parse_name(name) do
58+
{:ok, _, _} -> true
59+
:error -> false
60+
end
61+
end)
62+
63+
# Warn about invalid icon names
64+
for name <- invalid do
65+
Mix.shell().error(
66+
"PhoenixIconify: Invalid icon name format: #{inspect(name)}. " <>
67+
"Expected format: \"prefix:icon-name\" (e.g., \"heroicons:user\")"
68+
)
69+
end
70+
71+
# Read existing manifest
72+
manifest = Manifest.read()
73+
74+
# Find icons we don't have yet
75+
missing =
76+
valid
77+
|> Enum.reject(&Map.has_key?(manifest, &1))
78+
79+
if missing == [] do
80+
# All icons already cached
81+
{:ok, []}
82+
else
83+
# Fetch missing icons
84+
Mix.shell().info("PhoenixIconify: Fetching #{length(missing)} icon(s)...")
85+
86+
fetched = fetch_icons(missing)
87+
88+
# Update manifest
89+
updated = Map.merge(manifest, fetched)
90+
Manifest.write(updated)
91+
92+
# Clear the persistent_term cache so it reloads
93+
Manifest.clear_cache()
94+
95+
fetched_count = map_size(fetched)
96+
failed_count = length(missing) - fetched_count
97+
98+
if failed_count > 0 do
99+
Mix.shell().info("PhoenixIconify: Fetched #{fetched_count}, failed #{failed_count}")
100+
else
101+
Mix.shell().info("PhoenixIconify: Fetched #{fetched_count} icon(s)")
102+
end
103+
104+
{:ok, []}
105+
end
106+
end
107+
108+
defp fetch_icons(icon_names) do
109+
# Group by prefix for efficient fetching
110+
results =
111+
icon_names
112+
|> Enum.group_by(fn name ->
113+
case Iconify.parse_name(name) do
114+
{:ok, prefix, _icon_name} -> prefix
115+
:error -> nil
116+
end
117+
end)
118+
|> Enum.reject(fn {prefix, _} -> is_nil(prefix) end)
119+
|> Enum.flat_map(fn {prefix, names} ->
120+
fetch_prefix_icons(prefix, names)
121+
end)
122+
123+
# Warn about icons that couldn't be fetched
124+
fetched_names = Enum.map(results, fn {name, _} -> name end)
125+
not_found = icon_names -- fetched_names
126+
127+
for name <- not_found do
128+
Mix.shell().error("PhoenixIconify: Icon not found: #{name}")
129+
end
130+
131+
Map.new(results)
132+
end
133+
134+
defp fetch_prefix_icons(prefix, full_names) do
135+
# Extract just the icon names (without prefix)
136+
icon_names =
137+
full_names
138+
|> Enum.map(fn name ->
139+
{:ok, _prefix, icon_name} = Iconify.parse_name(name)
140+
icon_name
141+
end)
142+
143+
# Try to get icons from cache first
144+
case Cache.fetch_set(prefix) do
145+
{:ok, set} ->
146+
# Get icons from cached set
147+
Enum.flat_map(icon_names, fn icon_name ->
148+
case Iconify.Set.get(set, icon_name) do
149+
{:ok, icon} ->
150+
full_name = "#{prefix}:#{icon_name}"
151+
data = %{body: icon.body, viewbox: Iconify.Icon.viewbox(icon)}
152+
[{full_name, data}]
153+
154+
:error ->
155+
[]
156+
end
157+
end)
158+
159+
{:error, _} ->
160+
# Fall back to API for individual icons
161+
fetch_icons_from_api(prefix, icon_names)
162+
end
163+
end
164+
165+
defp fetch_icons_from_api(prefix, icon_names) do
166+
case Iconify.Fetcher.fetch_icons(prefix, icon_names) do
167+
{:ok, icons} ->
168+
Enum.map(icons, fn {name, icon} ->
169+
full_name = "#{prefix}:#{name}"
170+
data = %{body: icon.body, viewbox: Iconify.Icon.viewbox(icon)}
171+
{full_name, data}
172+
end)
173+
174+
{:error, reason} ->
175+
Mix.shell().error("PhoenixIconify: Failed to fetch #{prefix} icons: #{inspect(reason)}")
176+
[]
177+
end
178+
end
179+
end

0 commit comments

Comments
 (0)