Skip to content

Commit 61694f1

Browse files
committed
Initial commit: Iconify core library for Elixir
0 parents  commit 61694f1

17 files changed

Lines changed: 1389 additions & 0 deletions

.formatter.exs

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

.gitignore

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

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Changelog
2+
3+
## v0.1.0
4+
5+
- Initial release
6+
- Icon struct and Set parsing
7+
- SVG rendering with customizable attributes
8+
- Fetcher for NPM packages and Iconify API

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.

PLAN.md

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
# Iconify for Elixir - Implementation Plan
2+
3+
## Overview
4+
5+
Two separate hex packages:
6+
7+
- **`iconify`** - Core Elixir library for working with Iconify icons (no Phoenix dependency)
8+
- **`phoenix_iconify`** - Phoenix LiveView components and compile-time icon discovery
9+
10+
## How Iconify Publishes Icons
11+
12+
Iconify provides icons in multiple formats:
13+
14+
1. **`@iconify-json/{prefix}`** - Individual NPM packages per icon set (recommended)
15+
2. **`@iconify/json`** - One giant NPM package with all icons (~100MB)
16+
3. **Iconify API** - HTTP API at `api.iconify.design` for on-demand fetching
17+
18+
**IconifyJSON format:**
19+
```json
20+
{
21+
"prefix": "heroicons",
22+
"width": 24,
23+
"height": 24,
24+
"icons": {
25+
"user": {
26+
"body": "<path fill=\"currentColor\" d=\"...\"/>"
27+
}
28+
},
29+
"aliases": {
30+
"person": { "parent": "user" }
31+
}
32+
}
33+
```
34+
35+
## Package 1: `iconify`
36+
37+
Pure Elixir library. No Phoenix dependency.
38+
39+
### Dependencies
40+
- `jason` - JSON parsing
41+
- `req` - HTTP client (optional, for fetching)
42+
43+
### Public API
44+
45+
```elixir
46+
# Parse icon set from JSON
47+
{:ok, set} = Iconify.Set.load("path/to/heroicons.json")
48+
{:ok, set} = Iconify.Set.parse(json_string)
49+
50+
# Get icon data
51+
{:ok, icon} = Iconify.Set.get(set, "user")
52+
# => %Iconify.Icon{name: "user", body: "<path.../>", width: 24, height: 24}
53+
54+
# Build SVG string
55+
svg = Iconify.to_svg(icon)
56+
svg = Iconify.to_svg(icon, class: "w-6 h-6")
57+
# => "<svg xmlns=\"...\" class=\"w-6 h-6\" viewBox=\"0 0 24 24\">...</svg>"
58+
59+
# Fetch icon set from NPM
60+
{:ok, set} = Iconify.Fetcher.fetch_set("heroicons")
61+
62+
# Fetch individual icons from API
63+
{:ok, icons} = Iconify.Fetcher.fetch_icons("heroicons", ["user", "home"])
64+
```
65+
66+
### Data Structures
67+
68+
```elixir
69+
defmodule Iconify.Icon do
70+
defstruct [
71+
:name,
72+
:body, # SVG content (without <svg> wrapper)
73+
width: 24,
74+
height: 24,
75+
left: 0,
76+
top: 0
77+
]
78+
end
79+
80+
defmodule Iconify.Set do
81+
defstruct [
82+
:prefix,
83+
:icons, # %{name => %Icon{}}
84+
:aliases, # %{alias => parent_name}
85+
width: 24,
86+
height: 24
87+
]
88+
end
89+
```
90+
91+
### File Structure
92+
93+
```
94+
iconify/
95+
├── lib/
96+
│ ├── iconify.ex
97+
│ └── iconify/
98+
│ ├── icon.ex
99+
│ ├── set.ex
100+
│ ├── fetcher.ex
101+
│ └── svg.ex
102+
├── mix.exs
103+
└── test/
104+
```
105+
106+
## Package 2: `phoenix_iconify`
107+
108+
Phoenix LiveView integration with compile-time icon discovery.
109+
110+
### Dependencies
111+
- `iconify` - Core library
112+
- `phoenix_live_view` - Phoenix components
113+
114+
### User Experience
115+
116+
```elixir
117+
# 1. Add to mix.exs
118+
{:phoenix_iconify, "~> 1.0"}
119+
120+
# 2. Add compiler to mix.exs
121+
def project do
122+
[
123+
compilers: Mix.compilers() ++ [:phoenix_iconify],
124+
...
125+
]
126+
end
127+
128+
# 3. Import in your components
129+
import PhoenixIconify
130+
131+
# 4. Use in templates
132+
<.icon name="heroicons:user" class="w-5 h-5" />
133+
<.icon name="lucide:home" />
134+
<.icon name={@dynamic_icon} /> # Dynamic also works
135+
```
136+
137+
### How It Works
138+
139+
#### Compile-Time Discovery
140+
141+
Phoenix's HEEx compiler tracks all component calls via `__components_calls__` module attribute. Our Mix compiler:
142+
143+
1. Runs after Elixir compilation
144+
2. Iterates all modules with `__components_calls__/0`
145+
3. Filters calls to our `icon` component
146+
4. Extracts literal `name` attribute values
147+
5. Fetches missing icons from Iconify
148+
6. Updates manifest in `priv/`
149+
7. Triggers recompilation to embed new icons
150+
151+
#### Data Storage
152+
153+
```
154+
priv/
155+
iconify/
156+
cache/
157+
heroicons.json # Cached IconifyJSON
158+
lucide.json
159+
manifest.etf # Compiled icon data
160+
```
161+
162+
#### The Component
163+
164+
```elixir
165+
defmodule PhoenixIconify do
166+
use Phoenix.Component
167+
168+
# Embed icons at compile time
169+
@manifest_path Application.app_dir(:my_app, "priv/iconify/manifest.etf")
170+
171+
@icons if File.exists?(@manifest_path) do
172+
@external_resource @manifest_path
173+
File.read!(@manifest_path) |> :erlang.binary_to_term()
174+
else
175+
%{}
176+
end
177+
178+
attr :name, :string, required: true
179+
attr :class, :string, default: nil
180+
attr :rest, :global
181+
182+
def icon(assigns) do
183+
icon_data = Map.get(@icons, assigns.name) || fallback()
184+
assigns = assign(assigns, :body, icon_data.body)
185+
assigns = assign(assigns, :viewbox, "0 0 #{icon_data.width} #{icon_data.height}")
186+
187+
~H"""
188+
<svg xmlns="http://www.w3.org/2000/svg" viewBox={@viewbox} fill="currentColor"
189+
class={@class} aria-hidden="true" {@rest}>
190+
<%= Phoenix.HTML.raw(@body) %>
191+
</svg>
192+
"""
193+
end
194+
195+
defp fallback do
196+
%{body: ~S|<path d="..."/>|, width: 24, height: 24}
197+
end
198+
end
199+
```
200+
201+
### Mix Compiler
202+
203+
```elixir
204+
defmodule Mix.Tasks.Compile.PhoenixIconify do
205+
use Mix.Task.Compiler
206+
207+
@recursive true
208+
209+
def run(_args) do
210+
# 1. Collect icons from __components_calls__
211+
icons = PhoenixIconify.Collector.collect()
212+
213+
# 2. Load existing manifest
214+
manifest = PhoenixIconify.Manifest.read()
215+
216+
# 3. Find missing icons
217+
missing = Enum.reject(icons, &Map.has_key?(manifest, &1))
218+
219+
# 4. Fetch missing from Iconify
220+
fetched = PhoenixIconify.Fetcher.fetch(missing)
221+
222+
# 5. Update manifest
223+
updated = Map.merge(manifest, fetched)
224+
PhoenixIconify.Manifest.write(updated)
225+
226+
# 6. Report
227+
if missing != [] do
228+
Mix.shell().info("PhoenixIconify: Fetched #{length(missing)} new icons")
229+
end
230+
231+
{:ok, []}
232+
end
233+
end
234+
```
235+
236+
### Collector
237+
238+
```elixir
239+
defmodule PhoenixIconify.Collector do
240+
def collect do
241+
for module <- get_compiled_modules(),
242+
function_exported?(module, :__components_calls__, 0),
243+
call <- module.__components_calls__(),
244+
icon_name <- extract_icon_name(call),
245+
uniq: true do
246+
icon_name
247+
end
248+
end
249+
250+
defp extract_icon_name(%{component: component, props: props}) do
251+
# Check if this is our icon component
252+
if icon_component?(component) do
253+
case find_name_prop(props) do
254+
{:ok, name} when is_binary(name) -> [name]
255+
_ -> []
256+
end
257+
else
258+
[]
259+
end
260+
end
261+
262+
defp icon_component?({PhoenixIconify, :icon}), do: true
263+
defp icon_component?({_, :icon}), do: true # User's wrapper
264+
defp icon_component?(_), do: false
265+
266+
defp find_name_prop(props) do
267+
Enum.find_value(props, :error, fn
268+
%{name: :name, value: value} -> {:ok, extract_value(value)}
269+
_ -> nil
270+
end)
271+
end
272+
273+
defp extract_value({:string, value, _}), do: value
274+
defp extract_value(value) when is_binary(value), do: value
275+
defp extract_value(_), do: nil
276+
end
277+
```
278+
279+
### Configuration
280+
281+
```elixir
282+
# config/config.exs
283+
config :phoenix_iconify,
284+
fallback: "heroicons:question-mark-circle",
285+
cache_path: "priv/iconify" # Optional, this is default
286+
```
287+
288+
### File Structure
289+
290+
```
291+
phoenix_iconify/
292+
├── lib/
293+
│ ├── phoenix_iconify.ex
294+
│ ├── phoenix_iconify/
295+
│ │ ├── collector.ex
296+
│ │ ├── manifest.ex
297+
│ │ └── fetcher.ex
298+
│ └── mix/
299+
│ └── tasks/
300+
│ └── compile/
301+
│ └── phoenix_iconify.ex
302+
├── priv/
303+
│ └── iconify/
304+
│ └── .gitkeep
305+
├── mix.exs
306+
└── test/
307+
```
308+
309+
## Open Issues from Original Library
310+
311+
| Issue | Resolution |
312+
|-------|------------|
313+
| #24 CI setup unclear | Fixed - Icons discovered and cached automatically |
314+
| #20 LiveView 1.0 | Fixed - Proper deps |
315+
| #19 README update | Fixed - Simplified config |
316+
| #18 CHANGELOG | Will add |
317+
| #16 Release issues | Fixed - Icons embedded at compile time via `@external_resource` |
318+
| #6 Umbrella paths | Fixed - No path config needed, uses standard `priv/` |
319+
320+
## Benefits
321+
322+
1. **No runtime npm/yarn** - Icons fetched once at compile time
323+
2. **No JSON files in production** - Everything compiled into BEAM
324+
3. **Compile-time validation** - Typos caught during compilation
325+
4. **Optimal LiveView diffing** - Only attributes change, SVG content is static
326+
5. **User chooses icon sets** - Only used icons are bundled
327+
6. **Works in releases** - No file system access needed
328+
7. **Standard patterns** - Uses `priv/`, `@external_resource`, Mix compiler
329+
330+
## Implementation Status
331+
332+
### `iconify` core library ✅
333+
- [x] Icon struct (`Iconify.Icon`)
334+
- [x] Set parsing (`Iconify.Set`)
335+
- [x] SVG generation (`Iconify.Svg`)
336+
- [x] Fetcher - NPM + API (`Iconify.Fetcher`)
337+
- [x] Tests (33 passing)
338+
339+
### `phoenix_iconify`
340+
- [x] Basic component (`<.icon name="heroicons:user" />`)
341+
- [x] Scanner (extracts icons from HEEx source files)
342+
- [x] Manifest management (priv/iconify/manifest.etf)
343+
- [x] Mix compiler (`mix compile.phoenix_iconify`)
344+
- [x] Phoenix `hero-` prefix compatibility
345+
- [x] Compile-time warnings for unknown icons
346+
- [x] Tests (7 passing)
347+
348+
### Example App ✅
349+
- [x] Created iconify_example Phoenix app
350+
- [x] Integrated phoenix_iconify
351+
- [x] Tested with multiple icon sets (heroicons, lucide, mdi)
352+
- [x] Verified rendering works correctly

0 commit comments

Comments
 (0)