From a672e4b3707e111cb93dd891dc38c76fbab6c428 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 4 Oct 2026 15:13:32 +0400 Subject: [PATCH] Read runtime icons from every loaded application's manifest Only one manifest was read per node: the host application's in development, or the first loaded application shipping one in a release. A library using icons in its own components could not ship them, since the host's compiler scans only the host's sources and the library's manifest was never read. At runtime, icons now come from the manifests of every loaded application, sorted by name, with the application's own manifest last so it wins on conflicts. Applications without a code path are skipped rather than resolved against the working directory. The compiler and the mix tasks still read and write only the project's own manifest, and add_icon/3 with persist: true writes the own manifest plus the new icon instead of the merged runtime set, so a host never copies a library's icons into its manifest. --- CHANGELOG.md | 5 + README.md | 12 ++ lib/phoenix_iconify/manifest.ex | 63 +++++++++-- .../library_manifests_test.exs | 106 ++++++++++++++++++ 4 files changed, 177 insertions(+), 9 deletions(-) create mode 100644 test/phoenix_iconify/library_manifests_test.exs 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