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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

- Read runtime icons from the manifests of every loaded application, so libraries can ship `priv/iconify/manifest.json` with the icons their components use. The application's own manifest wins when two define the same icon; the compiler and mix tasks still read and write only the project's own manifest. `PhoenixIconify.Manifest.read_all/1` and `manifest_paths/0` expose the merged view.
- `PhoenixIconify.Manifest.add_icon/3` with `persist: true` writes the application's own manifest plus the new icon, not the merged runtime set.

## v0.3.6

- Fetch icons that a cached icon set lacks from the Iconify API. A set cached before an icon was added to the collection made new icons fail with "Icon not found" (#2).
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,18 @@ mix phoenix_iconify.cache list
mix phoenix_iconify.cache clear
```

## Icons in libraries

A library can use icons in its own components, and they reach the application without any configuration there. Add the compiler to the library's `mix.exs` as above, and ship its manifest in the Hex package:

```elixir
defp package do
[files: ~w(lib priv/iconify/manifest.json mix.exs README.md LICENSE)]
end
```

Commit `priv/iconify/manifest.json`, so applications compiling the library never fetch icons. At runtime, icons are read from the manifests of every loaded application; when two define the same icon, the application's own manifest wins. The compiler and the `mix phoenix_iconify.*` tasks only ever read and write the current project's own manifest.

## Volt projects

For projects created with [Volt](https://hex.pm/packages/volt), PhoenixIconify is the server-rendered option:
Expand Down
63 changes: 54 additions & 9 deletions lib/phoenix_iconify/manifest.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ defmodule PhoenixIconify.Manifest do
Manages the icon manifest stored in priv/.

The manifest contains all discovered icons, cached between compilations.

Each application compiled with the `:phoenix_iconify` compiler writes its
own manifest. At runtime, icons are read from the manifests of every
loaded application, so a library that ships `priv/iconify/manifest.json`
brings its icons along; the application's own manifest wins when two
define the same icon. The compiler and the mix tasks read and write only
the application's own manifest.
"""

@manifest_filename "manifest.json"
Expand Down Expand Up @@ -50,12 +57,45 @@ defmodule PhoenixIconify.Manifest do
end

@doc """
Gets icons from the manifest, loading from persistent storage on first use.
Reads and merges the manifests at `paths`, later ones winning.

Defaults to `manifest_paths/0`: the manifests of loaded libraries, then
the application's own.
"""
@spec read_all([Path.t()]) :: %{String.t() => Iconify.Icon.t()}
def read_all(paths \\ manifest_paths()) do
Enum.reduce(paths, %{}, fn path, icons -> Map.merge(icons, read(path)) end)
end

@doc """
Returns the manifests runtime icons come from: those shipped by loaded
applications, sorted by application, followed by the application's own
manifest, which may not exist yet.
"""
@spec manifest_paths() :: [Path.t()]
def manifest_paths do
own = manifest_path()

# Applications without a code path have no priv directory of their own.
libraries =
for app <- loaded_apps(),
priv = :code.priv_dir(app),
is_list(priv),
path = Path.join([List.to_string(priv), "iconify", @manifest_filename]),
path != own and File.regular?(path),
do: path

libraries ++ [own]
end

@doc """
Gets icons from every loaded manifest, loading from persistent storage on
first use.
"""
def get_icons do
case :persistent_term.get({__MODULE__, :icons}, nil) do
nil ->
icons = read()
icons = read_all()
:persistent_term.put({__MODULE__, :icons}, icons)
icons

Expand All @@ -68,7 +108,7 @@ defmodule PhoenixIconify.Manifest do
Reloads icons from disk into persistent_term.
"""
def reload do
icons = read()
icons = read_all()
:persistent_term.put({__MODULE__, :icons}, icons)
icons
end
Expand All @@ -84,11 +124,12 @@ defmodule PhoenixIconify.Manifest do
Adds an icon to the runtime cache and optionally persists to disk.
"""
def add_icon(name, %Iconify.Icon{} = icon, opts \\ []) do
updated = Map.put(get_icons(), name, %{icon | name: name})
:persistent_term.put({__MODULE__, :icons}, updated)
icon = %{icon | name: name}
:persistent_term.put({__MODULE__, :icons}, Map.put(get_icons(), name, icon))

# Only the application's own manifest, never icons from its libraries.
if Keyword.get(opts, :persist, false) do
write(updated)
write(Map.put(read(), name, icon))
end

:ok
Expand All @@ -109,12 +150,16 @@ defmodule PhoenixIconify.Manifest do
end

defp discover_manifest_path do
loaded_apps()
|> Enum.map(&Path.join([priv_dir(&1), "iconify", @manifest_filename]))
|> Enum.find(&File.regular?/1)
end

defp loaded_apps do
:application.loaded_applications()
|> Enum.map(fn {app, _description, _version} -> app end)
|> Enum.sort()
|> Enum.reject(&(&1 in [:phoenix_iconify, :iconify]))
|> Enum.map(&Path.join([priv_dir(&1), "iconify", @manifest_filename]))
|> Enum.find(&File.regular?/1)
|> Enum.sort()
end

defp mix_project_app do
Expand Down
106 changes: 106 additions & 0 deletions test/phoenix_iconify/library_manifests_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
defmodule PhoenixIconify.LibraryManifestsTest do
# Loads applications and changes :otp_app and the cached icons, all node-wide.
use ExUnit.Case, async: false

alias PhoenixIconify.Manifest

@moduletag :tmp_dir

defp icon(name, body), do: %Iconify.Icon{name: name, body: body, width: 24, height: 24}

# An application on the code path with its own priv/iconify/manifest.json,
# as a library compiled with the :phoenix_iconify compiler ships it. The
# directory is named after the application so :code.priv_dir/1 finds it.
defp load_app(tmp_dir, icons) do
app = :"iconify_fixture_#{System.unique_integer([:positive])}"
dir = Path.join(tmp_dir, Atom.to_string(app))
ebin = Path.join(dir, "ebin")
File.mkdir_p!(ebin)

spec = {:application, app, [vsn: ~c"1.0.0", modules: [], applications: []]}
File.write!(Path.join(ebin, "#{app}.app"), :io_lib.format(~c"~p.~n", [spec]))

if icons != %{},
do: Manifest.write(icons, Path.join([dir, "priv", "iconify", "manifest.json"]))

true = Code.prepend_path(ebin)
:ok = Application.load(app)

on_exit(fn ->
Application.unload(app)
Code.delete_path(ebin)
end)

app
end

defp use_own_app(app) do
previous = Application.get_env(:phoenix_iconify, :otp_app)
Application.put_env(:phoenix_iconify, :otp_app, app)
Manifest.clear_cache()

on_exit(fn ->
Manifest.clear_cache()

if previous,
do: Application.put_env(:phoenix_iconify, :otp_app, previous),
else: Application.delete_env(:phoenix_iconify, :otp_app)
end)
end

test "read_all/1 merges manifests, later ones winning", %{tmp_dir: tmp_dir} do
library = Path.join(tmp_dir, "library.json")
own = Path.join(tmp_dir, "own.json")

Manifest.write(
%{"lucide:a" => icon("lucide:a", "lib"), "lucide:b" => icon("lucide:b", "lib")},
library
)

Manifest.write(%{"lucide:b" => icon("lucide:b", "own")}, own)

icons = Manifest.read_all([library, own, Path.join(tmp_dir, "missing.json")])

assert icons["lucide:a"].body == "lib"
assert icons["lucide:b"].body == "own"
end

test "runtime icons include loaded libraries', the application's own winning", %{
tmp_dir: tmp_dir
} do
library =
load_app(tmp_dir, %{
"lucide:shared" => icon("lucide:shared", "library"),
"lucide:only-lib" => icon("lucide:only-lib", "l")
})

own = load_app(tmp_dir, %{"lucide:shared" => icon("lucide:shared", "own")})
use_own_app(own)

paths = Manifest.manifest_paths()

library_path =
Path.join([List.to_string(:code.priv_dir(library)), "iconify", "manifest.json"])

assert library_path in paths
assert List.last(paths) == Manifest.manifest_path()

icons = Manifest.get_icons()
assert icons["lucide:only-lib"].body == "l"
assert icons["lucide:shared"].body == "own"
assert PhoenixIconify.icon_exists?("lucide:only-lib")
end

test "add_icon/3 persists only the application's own icons", %{tmp_dir: tmp_dir} do
load_app(tmp_dir, %{"lucide:from-lib" => icon("lucide:from-lib", "l")})
own = load_app(tmp_dir, %{})
use_own_app(own)

assert Map.has_key?(Manifest.get_icons(), "lucide:from-lib")

:ok = Manifest.add_icon("lucide:new", icon("lucide:new", "n"), persist: true)

assert Map.keys(Manifest.read()) == ["lucide:new"]
assert Map.has_key?(Manifest.get_icons(), "lucide:from-lib")
end
end
Loading