diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e617a7..2f60178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/README.md b/README.md index 7d81f54..8c74631 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/lib/phoenix_iconify/manifest.ex b/lib/phoenix_iconify/manifest.ex index d679ae7..48718f4 100644 --- a/lib/phoenix_iconify/manifest.ex +++ b/lib/phoenix_iconify/manifest.ex @@ -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" @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/test/phoenix_iconify/library_manifests_test.exs b/test/phoenix_iconify/library_manifests_test.exs new file mode 100644 index 0000000..229c662 --- /dev/null +++ b/test/phoenix_iconify/library_manifests_test.exs @@ -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