A host installs a mountable library with one command:

mix igniter.install my_lib

Igniter adds the dependency, then runs the library's installer, which edits the host's code safely and shows each change as a diff before it writes. Every step is idempotent: running the installer again changes nothing.

In one use

PhoenixMountable.Install declares the installer:

defmodule Mix.Tasks.MyLib.Install do
  use PhoenixMountable.Install,
    otp_app: :my_lib,
    mounts: [
      {MyLib.Router, "/my_lib", scope: "/admin", options: "resolver: {web}.MyLibResolver"}
    ],
    migrations: MyLib.Migrations,
    hook: {"MyLib", "my_lib"},
    css: ["my_lib.css", {"my_lib-dark.css", :comment}],
    manual: "See https://hexdocs.pm/my_lib/installation.html"
end

The steps run in this order, each when its option is given:

OptionStep
:mountsmounts each definition in the router: in a scope (:scope, "/" by default) with pipelines (:pipelines, [:browser]), with options ({web} is the host's web module)
:migrationswrites the migration that installs or upgrades the library's tables
:hookadds import { MyLib } from "my_lib" and the hook to the LiveSocket of assets/js/app.js
:cssimports the library's style sheets in assets/css/app.css, a theme as a comment
:tailwindadds the library to the host's Tailwind sources
:supervisorstarts the mounts' processes at boot

Then the library's own steps:

@impl PhoenixMountable.Install
def install(igniter) do
  igniter
  |> Igniter.Project.Application.add_new_child({MyLib, repo: MyApp.Repo})
  |> Igniter.Project.Module.create_module(MyAppWeb.MyLibResolver, "...")
end

Igniter stays an optional dependency of the library. In a host without it, mix my_lib.install says how to get it, and shows :manual.

The building blocks

PhoenixMountable.Igniter has each step as a function, for an installer written by hand:

defmodule Mix.Tasks.MyLib.Install do
  use Igniter.Mix.Task

  @impl Igniter.Mix.Task
  def info(_argv, _parent), do: %Igniter.Mix.Task.Info{group: :my_lib}

  @impl Igniter.Mix.Task
  def igniter(igniter) do
    igniter
    |> PhoenixMountable.Igniter.mount(MyLib.Router, "/my_lib")
    |> PhoenixMountable.Igniter.gen_migration(MyLib.Migrations)
    |> PhoenixMountable.Igniter.add_tailwind_source(:my_lib)
  end
end
  • mount/4 adds the import and the mount call to the router's scope with the given path and pipelines, and the library to the host's .formatter.exs. A library whose several definitions are re-exported by one module passes import: MyLib.Router.
  • gen_migration/3 writes the migration from the version the host's migrations pin to the latest, or nothing when the host is up to date, with Igniter's own migration generator. Call it from the library's upgrader too.
  • add_hook/4 and add_css_imports/4 edit app.js and app.css, for a library whose components render in the host's own pages (a button in its header, a palette). The edits are PhoenixMountable.Igniter.HostAssets; where app.js cannot be edited safely, the host gets the lines to add.
  • add_supervisor/2 starts the mounts' processes at boot.
  • add_tailwind_source/2 adds the @source (Tailwind v4) or content (Tailwind v3) line for libraries styled by the host.

Mix tasks

  • mix phoenix_mountable.gen.migration MyLib.Migrations writes the install or upgrade migration. A library wraps it in its own task.
  • mix phoenix_mountable.check checks every library's schema, for CI.
  • mix phoenix_mountable.backfill MyLib.Migrations runs deferred backfills.

Formatting

Export the mount macro in the library's .formatter.exs, so that a host that imports the library's formatting (mount/4 adds it) writes my_lib "/my_lib" without parentheses:

[
  import_deps: [:phoenix_mountable],
  export: [locals_without_parens: [my_lib: 1, my_lib: 2]]
]

import_deps: [:phoenix_mountable] formats the definition's DSL (routes, live, options).

Testing an installer

Igniter.Test.test_project/1 with the files the installer edits (a router, assets/js/app.js, assets/css/app.css) runs it without a real app, and without the phx_new archive that phx_test_project/1 needs:

igniter =
  Igniter.Test.test_project(files: %{"lib/test_web/router.ex" => router, "assets/js/app.js" => app_js})
  |> Igniter.compose_task(Mix.Tasks.MyLib.Install, [])