defmodule Mix.Tasks.Compile.PhoenixKitCssSources do @moduledoc """ Mix compiler that auto-generates CSS @source directives for PhoenixKit modules. Discovers external modules via `PhoenixKit.ModuleDiscovery`, calls `css_sources/0` on each, resolves their paths, and writes `assets/css/_phoenix_kit_sources.css`. The parent app's `app.css` imports this file. Zero-config: add a module with `css_sources/0` and it gets picked up automatically. ## Setup Add to your `mix.exs` compilers list: compilers: [:phoenix_kit_css_sources, :phoenix_live_view] ++ Mix.compilers() And import the generated file in `assets/css/app.css`: @import "./_phoenix_kit_sources.css"; """ use Mix.Task.Compiler require Logger @generated_file "assets/css/_phoenix_kit_sources.css" @impl true def run(_args) do deps = Mix.Project.config()[:deps] || [] source_lines = PhoenixKit.ModuleDiscovery.discover_external_modules() |> Enum.flat_map(fn mod -> if Code.ensure_loaded?(mod) and function_exported?(mod, :css_sources, 0) do mod.css_sources() else [] end end) |> Enum.uniq() |> Enum.sort() |> Enum.map(&format_source(&1, deps)) maybe_warn_zero_sources(source_lines) content = """ /* Auto-generated by PhoenixKit — do not edit manually. Regenerated on each compilation from css_sources/0 callbacks. */ #{Enum.join(source_lines, "\n")} """ path = Path.join(File.cwd!(), @generated_file) existing = case File.read(path) do {:ok, data} -> data _ -> nil end if existing != content do File.mkdir_p!(Path.dirname(path)) File.write!(path, content) Mix.shell().info( "[PhoenixKit] Updated #{@generated_file} with #{length(source_lines)} source(s)" ) end {:ok, []} end # A silent empty file is the worst failure mode of CSS auto-discovery: the host's # admin UI loses every responsive/variant utility from the dep with no error. When # we emit zero @source lines but phoenix_kit-dependent deps are present on disk, # surface it loudly so it isn't a multi-hour CSS mystery. defp maybe_warn_zero_sources([]) do self = Mix.Project.config()[:app] case PhoenixKit.ModuleDiscovery.phoenix_kit_dependent_apps() -- [self, :phoenix_kit] do [] -> :ok apps -> Mix.shell().error( "[PhoenixKit] #{@generated_file} generated with 0 @source lines, but found " <> "phoenix_kit-dependent dep(s): #{inspect(apps)}. Their Tailwind classes will " <> "NOT be compiled — responsive/variant utilities used only inside those deps " <> "will be missing from the bundle. Ensure each module implements css_sources/0, " <> "or set `config :phoenix_kit, modules: [...]` as a fallback." ) end end defp maybe_warn_zero_sources(_), do: :ok # Absolute paths are emitted verbatim — prepending `../../` would # produce `@source "../..//abs/path";` which Tailwind can't resolve # reliably. Relative paths stay relative to the generated file at # `assets/css/_phoenix_kit_sources.css` (two levels up to project root). # String entries from css_sources/0 — treated as literal paths (abs or rel). defp format_source(entry, _deps) when is_binary(entry), do: source_for_path(entry) # Atom entries — resolved via parent app's mix.exs deps. defp format_source(entry, deps) when is_atom(entry) do app_name = to_string(entry) case find_dep_path(entry, deps) do {:path, path} -> source_for_path(path) :hex -> "@source \"../../deps/#{app_name}\";" :not_found -> "@source \"../../deps/#{app_name}\";" end end defp source_for_path("/" <> _ = abs_path), do: "@source \"#{abs_path}\";" defp source_for_path(path), do: "@source \"../../#{path}\";" defp find_dep_path(app_name, deps) do dep = Enum.find(deps, fn {name, opts} when is_list(opts) -> name == app_name {name, _version} -> name == app_name {name, _version, opts} when is_list(opts) -> name == app_name _ -> false end) case dep do {_, opts} when is_list(opts) -> if path = Keyword.get(opts, :path), do: {:path, path}, else: :hex {_, _version, opts} when is_list(opts) -> if path = Keyword.get(opts, :path), do: {:path, path}, else: :hex {_, _version} -> :hex nil -> :not_found end end end