|
| 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