defmodule Mix.Tasks.Compile.PhoenixKitJsSources do @moduledoc """ Mix compiler that bundles external-module JavaScript hooks for the host app. A LiveView JS hook must be present in the host's single `LiveSocket` at construction time — a nested LiveView cannot register one at runtime. Modules declare their prebuilt hook bundles via `js_sources/0` (see `PhoenixKit.Module`); this compiler discovers them and writes one aggregate file the host loads with a single ` Failures are loud: a declared bundle that can't be resolved raises a compile error rather than silently shipping a chart with "unknown hook" console errors. """ use Mix.Task.Compiler require Logger @output "priv/static/assets/vendor/phoenix_kit_modules.js" @impl true def run(_args) do # Ensure all dep applications are loaded so module discovery + priv_dir work. for dep <- Mix.Dep.cached() do Application.ensure_loaded(dep.app) end specs = collect_specs() content = build_content(specs) path = Path.join(File.cwd!(), @output) write_if_changed(path, content, length(specs)) {:ok, []} end # ── Discovery ──────────────────────────────────────────────────── # Returns a deterministic list of %{app, file, global, source} maps. defp collect_specs do PhoenixKit.ModuleDiscovery.discover_external_modules() |> Enum.flat_map(fn mod -> if Code.ensure_loaded?(mod) and function_exported?(mod, :js_sources, 0) do mod.js_sources() else [] end end) |> Enum.map(&normalize_entry/1) # Deterministic order; dedupe identical (app, file) declarations. |> Enum.uniq_by(fn s -> {s.app, s.file} end) |> Enum.sort_by(fn s -> {to_string(s.app), s.file} end) |> tap(&check_unique_globals/1) |> Enum.map(&resolve_source/1) end # Two distinct bundles assigning the same window. would clobber each # other (last bundle wins; the merge dedupes the global), silently dropping the # earlier module's hooks. Fail loud instead. @doc false def check_unique_globals(specs) do dupes = specs |> Enum.group_by(& &1.global) |> Enum.filter(fn {_global, entries} -> length(entries) > 1 end) unless dupes == [] do detail = Enum.map_join(dupes, "\n", fn {global, entries} -> apps = Enum.map_join(entries, ", ", fn e -> "#{e.app}:#{e.file}" end) " window.#{global} <- #{apps}" end) Mix.raise(""" Multiple js_sources/0 bundles declare the same window global, so the later bundle would clobber the earlier one's hooks: #{detail} Each module's bundle must assign a unique window.. Rename one. """) end :ok end @doc false def normalize_entry(%{app: app, file: file, global: global}) when is_atom(app) and is_binary(file) and is_binary(global) do # `global` is emitted as `window.` in the generated JS, so an # invalid identifier (e.g. "foo-bar") would produce broken JS — fail loud. unless Regex.match?(~r/^[A-Za-z_$][A-Za-z0-9_$]*$/, global) do Mix.raise(""" Invalid js_sources/0 :global #{inspect(global)} — must be a valid JavaScript identifier (it is emitted as window. and folded into PhoenixKitHooks). """) end %{app: app, file: file, global: global} end def normalize_entry(other) do Mix.raise(""" Invalid js_sources/0 entry: #{inspect(other)} Each entry must be a map with :app (atom), :file (string, relative to the app's priv/), and :global (string, the window. the bundle assigns). """) end # Resolve the bundle's absolute path via the app's priv dir. Fail loud. defp resolve_source(%{app: app, file: file} = spec) do priv = case :code.priv_dir(app) do {:error, :bad_name} -> Mix.raise(""" js_sources/0 references app #{inspect(app)}, but it isn't an available application. Add it as a dependency of the module declaring it. """) dir -> to_string(dir) end source = Path.join(priv, file) unless File.exists?(source) do Mix.raise(""" js_sources/0 bundle not found: #{source} App #{inspect(app)} declared js_sources file #{inspect(file)} (resolved under its priv/), but no such file exists. The bundle must ship in the app's priv/. """) end Map.put(spec, :source, source) end # ── Content ────────────────────────────────────────────────────── @doc false def build_content([]) do """ /* Auto-generated by PhoenixKit — do not edit manually. No external module declares js_sources/0; this file is intentionally empty. */ """ end def build_content(specs) do bundles = Enum.map_join(specs, "\n", fn %{app: app, source: source} -> body = File.read!(source) # Wrap each prebuilt bundle in an IIFE so its top-level declarations # can't collide with another bundle's. Bundles export via window., # which survives the wrapper. Leading `;` guards against ASI hazards. """ /* #{app} */ ;(function(){ #{body} })(); """ end) globals = specs |> Enum.map(& &1.global) |> Enum.uniq() |> Enum.map_join(",", fn g -> "window.#{g}||{}" end) # NOTE: `check_unique_globals/1` guarantees distinct window. names, # but this Object.assign is still last-write-wins on the HOOK NAMES inside # each bundle — and it folds over the core window.PhoenixKitHooks too. Two # modules exporting a same-named hook (or one shadowing a core hook) clobber # silently; we can't detect that without parsing the bundles. js_sources/0's # docs tell modules to namespace their hook names accordingly. """ /* Auto-generated by PhoenixKit — do not edit manually. Concatenated module hook bundles (js_sources/0), loaded before app.js. */ #{bundles} /* Fold each module's hooks into window.PhoenixKitHooks (spread into LiveSocket by app.js). */ window.PhoenixKitHooks=Object.assign(window.PhoenixKitHooks||{},#{globals}); """ end defp write_if_changed(path, content, spec_count) do 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 #{@output} with #{spec_count} module hook bundle(s)") end end end