if Code.ensure_loaded?(Igniter) do
defmodule Mix.Tasks.Vitex.Install do
@moduledoc """
Installs and configures Phoenix Vite in a Phoenix application using Igniter.
This installer:
1. Creates vite.config.js with appropriate configuration
2. Updates package.json with Vite dependencies and scripts
3. Adds the Vite watcher to the development configuration
4. Updates the root layout template to use Vite helpers
5. Creates or updates asset files for Vite
## Usage
$ mix vitex.install
## Options
--ssr Enable Server-Side Rendering support
--tls Enable automatic TLS certificate detection
--react Enable React Fast Refresh support
--typescript Enable TypeScript support
--inertia Enable Inertia.js support (automatically enables React)
--shadcn Enable shadcn/ui component library (requires --typescript and either --react or --inertia)
--base-color Base color for shadcn/ui theme (neutral,gray, zinc, stone, slate) - defaults to neutral
--bun Use Bun as the package manager instead of npm
--yes Don't prompt for confirmations
## Package Manager Support
Vitex supports two approaches for package management:
1. **System Package Managers** (npm, pnpm, yarn): Uses whatever is installed on the system
2. **Elixir-Managed Bun** (--bun flag): Uses the Elixir bun package to download and manage the bun executable
The --bun option is special because:
- It adds a Mix dependency for bun
- The bun executable is managed at _build/bun
- It can use Bun workspaces for Phoenix JS dependencies
- Mix tasks handle the bun installation lifecycle
"""
use Igniter.Mix.Task
require Igniter.Code.Common
alias Mix.Tasks.Vitex.Install.BunIntegration
@impl Igniter.Mix.Task
def info(_argv, _parent) do
%Igniter.Mix.Task.Info{
schema: [
ssr: :boolean,
tls: :boolean,
react: :boolean,
typescript: :boolean,
inertia: :boolean,
camelize_props: :boolean,
history_encrypt: :boolean,
yes: :boolean,
bun: :boolean,
shadcn: :boolean,
base_color: :string
],
defaults: [],
positional: [],
composes: ["deps.get"]
}
end
@impl Igniter.Mix.Task
def igniter(igniter) do
# If inertia is enabled, also enable react
igniter =
if igniter.args.options[:inertia] do
put_in(igniter.args.options[:react], true)
else
igniter
end
# Validate shadcn requirements
igniter =
if igniter.args.options[:shadcn] do
cond do
!igniter.args.options[:typescript] ->
Igniter.add_issue(igniter, """
The --shadcn flag requires TypeScript to be enabled.
Please add the --typescript flag to use shadcn/ui.
""")
!(igniter.args.options[:react] || igniter.args.options[:inertia]) ->
Igniter.add_issue(igniter, """
The --shadcn flag requires either React or Inertia.js to be enabled.
Please add either the --react or --inertia flag to use shadcn/ui.
""")
true ->
igniter
end
else
igniter
end
igniter
|> maybe_add_inertia_dep()
|> BunIntegration.integrate()
|> setup_html_helpers()
|> maybe_setup_inertia()
|> create_vite_config()
|> update_package_json()
|> setup_watcher_for_system_package_managers()
|> remove_old_watchers()
|> update_root_layout()
|> setup_assets()
|> maybe_setup_shadcn()
|> update_mix_aliases_for_system_package_managers()
|> print_next_steps()
end
def maybe_add_inertia_dep(igniter) do
if igniter.args.options[:inertia] do
Igniter.Project.Deps.add_dep(igniter, {:inertia, "~> 2.4"})
else
igniter
end
end
# Bun integration is now handled by BunIntegration.integrate/1
# Test helpers - these delegate to the appropriate functions
def update_mix_aliases(igniter) do
if BunIntegration.using_bun?(igniter) do
BunIntegration.integrate(igniter)
else
update_mix_aliases_for_system_package_managers(igniter)
end
end
def setup_watcher(igniter) do
if BunIntegration.using_bun?(igniter) do
BunIntegration.integrate(igniter)
else
setup_watcher_for_system_package_managers(igniter)
end
end
def maybe_add_bun_dep(igniter) do
BunIntegration.integrate(igniter)
end
def maybe_setup_bun_config(igniter) do
BunIntegration.integrate(igniter)
end
def setup_html_helpers(igniter) do
update_web_ex_helper(igniter, :html, fn zipper ->
import_code = """
alias Vitex, as: Vite
"""
with {:ok, zipper} <- move_to_last_import_or_alias(zipper) do
{:ok, Igniter.Code.Common.add_code(zipper, import_code)}
end
end)
end
def maybe_setup_inertia(igniter) do
if igniter.args.options[:inertia] do
igniter
|> setup_inertia_controller_helpers()
|> setup_inertia_html_helpers()
|> setup_inertia_router()
|> add_inertia_config()
|> setup_page_controller()
else
igniter
end
end
defp setup_inertia_controller_helpers(igniter) do
update_web_ex_helper(igniter, :controller, fn zipper ->
import_code = "import Inertia.Controller"
with {:ok, zipper} <- move_to_last_import_or_alias(zipper) do
{:ok, Igniter.Code.Common.add_code(zipper, import_code)}
end
end)
end
defp setup_inertia_html_helpers(igniter) do
update_web_ex_helper(igniter, :html, fn zipper ->
import_code = """
import Inertia.HTML
"""
with {:ok, zipper} <- move_to_last_import_or_alias(zipper) do
{:ok, Igniter.Code.Common.add_code(zipper, import_code)}
end
end)
end
defp setup_inertia_router(igniter) do
igniter
|> Igniter.Libs.Phoenix.append_to_pipeline(:browser, "plug Inertia.Plug")
|> update_router_pipeline()
end
defp update_router_pipeline(igniter) do
web_module = web_module_name(igniter)
inertia_pipeline = """
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {#{web_module}.Layouts, :inertia_root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug Inertia.Plug
"""
igniter
|> Igniter.Libs.Phoenix.add_pipeline(
:inertia,
inertia_pipeline,
arg2: web_module
)
|> Igniter.Libs.Phoenix.add_scope(
"/inertia",
"""
pipe_through :inertia
get "/", PageController, :inertia
""",
arg2: web_module
)
end
defp web_module_name(igniter) do
Igniter.Libs.Phoenix.web_module(igniter)
end
defp setup_page_controller(igniter) do
web_module = Igniter.Libs.Phoenix.web_module(igniter)
controller_module = Module.concat([web_module, PageController])
# Check if the PageController already exists
case Igniter.Project.Module.find_module(igniter, controller_module) do
{:ok, {igniter, _source, _zipper}} ->
# Controller exists, add the inertia function to it
igniter
|> Igniter.Project.Module.find_and_update_module!(controller_module, fn zipper ->
# Check if the inertia function already exists
case Igniter.Code.Function.move_to_def(zipper, :inertia, 2) do
{:ok, _zipper} ->
# Function already exists, don't modify
{:ok, zipper}
:error ->
# Function doesn't exist, add it
inertia_function = """
def inertia(conn, _params) do
conn
|> assign_prop(:greeting, "Hello from Inertia.js and Phoenix!")
|> render_inertia("Home")
end
"""
# Try to add the function after the last def in the module
case Igniter.Code.Common.move_to_last(zipper, fn zipper ->
node = Sourceror.Zipper.node(zipper)
match?({:def, _, _}, node) or match?({:defp, _, _}, node)
end) do
{:ok, zipper} ->
case Igniter.Code.Common.add_code(zipper, inertia_function) do
{:ok, updated_zipper} -> {:ok, updated_zipper}
updated_zipper -> {:ok, updated_zipper}
end
:error ->
# If no defs found, return unchanged
{:ok, zipper}
end
end
end)
|> Igniter.add_notice("""
Added inertia action to existing PageController.
The action renders the Home component with a greeting prop.
""")
{:error, igniter} ->
# Controller doesn't exist, create it
controller_path =
controller_module
|> inspect()
|> String.replace(".", "/")
|> Macro.underscore()
|> then(&"lib/#{&1}.ex")
controller_content = """
defmodule #{inspect(controller_module)} do
use #{inspect(web_module)}, :controller
def inertia(conn, _params) do
conn
|> assign_prop(:greeting, "Hello from Inertia.js and Phoenix!")
|> render_inertia("Home")
end
end
"""
igniter
|> Igniter.create_new_file(controller_path, controller_content)
|> Igniter.add_notice("""
Created PageController with Inertia action at #{controller_path}
The controller renders the Home component with a greeting prop.
""")
end
end
defp add_inertia_config(igniter) do
{igniter, endpoint_module} = Igniter.Libs.Phoenix.select_endpoint(igniter)
# Determine configuration based on options
camelize_props = igniter.args.options[:camelize_props] || false
history_encryption = igniter.args.options[:history_encrypt] || false
config_options = [
endpoint: endpoint_module
]
# Add camelize_props config if specified
config_options =
if camelize_props do
Keyword.put(config_options, :camelize_props, true)
else
config_options
end
# Add history encryption config if specified
config_options =
if history_encryption do
Keyword.put(config_options, :history, encrypt: true)
else
config_options
end
# Add the configuration to config.exs
Enum.reduce(config_options, igniter, fn {key, value}, igniter ->
Igniter.Project.Config.configure(
igniter,
"config.exs",
:inertia,
[key],
value
)
end)
end
# Run an update function within the quote do ... end block inside a *web.ex helper function
defp update_web_ex_helper(igniter, helper_name, update_fun) do
web_module = Igniter.Libs.Phoenix.web_module(igniter)
case Igniter.Project.Module.find_module(igniter, web_module) do
{:ok, {igniter, _source, _zipper}} ->
Igniter.Project.Module.find_and_update_module!(igniter, web_module, fn zipper ->
with {:ok, zipper} <- Igniter.Code.Function.move_to_def(zipper, helper_name, 0),
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do
Igniter.Code.Common.within(zipper, update_fun)
else
:error ->
{:warning, "Could not find #{helper_name}/0 function in #{inspect(web_module)}"}
end
end)
{:error, igniter} ->
Igniter.add_warning(
igniter,
"Could not find web module #{inspect(web_module)}. You may need to manually add Vitex helpers."
)
end
end
defp move_to_last_import_or_alias(zipper) do
# Try to find the last import first
case Igniter.Code.Common.move_to_last(
zipper,
&Igniter.Code.Function.function_call?(&1, :import)
) do
{:ok, zipper} ->
{:ok, zipper}
_ ->
# If no imports, try to find the last alias
Igniter.Code.Common.move_to_last(
zipper,
&Igniter.Code.Function.function_call?(&1, :alias)
)
end
end
# Common detection helpers
defp detect_tailwind(igniter) do
css_path = "assets/css/app.css"
if Igniter.exists?(igniter, css_path) do
updated_igniter = Igniter.include_existing_file(igniter, css_path)
source = Rewrite.source!(updated_igniter.rewrite, css_path)
content = Rewrite.Source.get(source, :content)
has_tailwind = String.contains?(content, "@import \"tailwindcss\"")
{updated_igniter, has_tailwind}
else
{igniter, false}
end
end
def create_vite_config(igniter) do
{igniter, has_tailwind} = detect_tailwind(igniter)
config = build_vite_config(igniter.args.options, has_tailwind)
Igniter.create_new_file(igniter, "assets/vite.config.js", config, on_exists: :skip)
end
defp build_vite_config(options, has_tailwind) do
imports = build_vite_imports(options, has_tailwind)
plugins = build_vite_plugins(options, has_tailwind)
input_files = build_input_files(options)
additional_opts = build_additional_options(options)
path_import = if options[:shadcn], do: "\nimport path from 'path'", else: ""
"""
import { defineConfig } from 'vite'
import phoenix from '../deps/vitex/priv/static/vitex'#{path_import}#{imports}
export default defineConfig({
plugins: [#{plugins}
phoenix({
input: #{input_files},
publicDirectory: '../priv/static',
buildDirectory: 'assets',
hotFile: '../priv/hot',
manifestPath: '../priv/static/assets/manifest.json',#{additional_opts}
})
],#{build_resolve_config(options)}
})
"""
end
defp build_vite_imports(options, has_tailwind) do
imports = []
imports =
if options[:react],
do: imports ++ ["\nimport react from '@vitejs/plugin-react'"],
else: imports
imports =
if has_tailwind,
do: imports ++ ["\nimport tailwindcss from '@tailwindcss/vite'"],
else: imports
Enum.join(imports)
end
defp build_vite_plugins(options, has_tailwind) do
plugins = []
plugins = if options[:react], do: plugins ++ ["\n react(),"], else: plugins
plugins = if has_tailwind, do: plugins ++ ["\n tailwindcss(),"], else: plugins
Enum.join(plugins)
end
defp build_input_files(options) do
typescript = options[:typescript] || false
inertia = options[:inertia] || false
entry_extension = determine_entry_extension(typescript, inertia)
if inertia && typescript do
"['js/app.tsx', 'js/app.ts', 'css/app.css']"
else
"['js/app.#{entry_extension}', 'css/app.css']"
end
end
defp determine_entry_extension(typescript, inertia) do
cond do
inertia && typescript -> "tsx"
inertia -> "jsx"
typescript -> "ts"
true -> "js"
end
end
defp determine_app_extension(typescript, _react, _inertia) do
# For root.html.heex, we always use app.js or app.ts
# The JSX/TSX files are separate entry points for React/Inertia
if typescript, do: "ts", else: "js"
end
defp build_additional_options(options) do
config_items = []
config_items = maybe_add_config(config_items, "refresh: true", true)
config_items =
maybe_add_config(
config_items,
"reactRefresh: true",
!!(options[:react] || options[:inertia])
)
config_items = maybe_add_config(config_items, "detectTls: true", !!options[:tls])
config_items = maybe_add_config(config_items, "ssr: 'js/ssr.js'", !!options[:ssr])
case config_items do
[] -> ""
items -> "\n" <> Enum.map_join(items, "\n", &" #{&1},")
end
end
defp build_resolve_config(options) do
cond do
options[:shadcn] ->
"""
resolve: {
alias: {
"@": path.resolve(__dirname, "./js")
}
}
"""
options[:typescript] ->
"""
resolve: {
alias: {
'@': '/js'
}
}
"""
true ->
""
end
end
defp maybe_add_config(configs, _config, false), do: configs
defp maybe_add_config(configs, config, true), do: [config | configs]
def update_package_json(igniter) do
igniter
|> detect_project_features()
|> build_and_write_package_json()
|> update_vendor_imports()
|> queue_npm_install()
end
defp detect_project_features(igniter) do
{igniter, has_tailwind} = detect_tailwind(igniter)
{igniter, has_topbar} = detect_topbar(igniter)
{igniter, has_daisyui} = detect_daisyui(igniter)
features = %{
react: igniter.args.options[:react] || false,
typescript: igniter.args.options[:typescript] || false,
inertia: igniter.args.options[:inertia] || false,
shadcn: igniter.args.options[:shadcn] || false,
tailwind: has_tailwind,
topbar: has_topbar,
daisyui: has_daisyui
}
Igniter.assign(igniter, :detected_features, features)
end
defp build_and_write_package_json(igniter) do
features = igniter.assigns[:detected_features]
dependencies = build_dependencies(features)
dev_dependencies = build_dev_dependencies(features)
package_json = %{
"name" => Igniter.Project.Application.app_name(igniter) |> to_string(),
"version" => "0.0.0",
"type" => "module",
"private" => true,
"dependencies" => dependencies,
"devDependencies" => dev_dependencies,
"scripts" => %{
"dev" => "vite",
"build" => "vite build"
}
}
# Add Bun workspaces for Phoenix JS libraries if needed
package_json = BunIntegration.update_package_json(package_json, igniter)
content = Jason.encode!(package_json, pretty: true)
Igniter.create_new_file(igniter, "assets/package.json", content, on_exists: :skip)
end
defp build_dependencies(features) do
deps = %{"vite" => "^7.0.0"}
deps = if features.topbar, do: Map.put(deps, "topbar", "^3.0.0"), else: deps
deps =
if features.tailwind do
Map.merge(deps, %{
"@tailwindcss/vite" => "^4.1.0",
"tailwindcss" => "^4.1.0"
})
else
deps
end
deps = if features.daisyui, do: Map.put(deps, "daisyui", "latest"), else: deps
deps =
if features.react do
Map.merge(deps, %{
"react" => "^19.1.0",
"react-dom" => "^19.1.0",
"@vitejs/plugin-react" => "^4.3.4"
})
else
deps
end
if features.inertia do
Map.merge(deps, %{
"@inertiajs/react" => "^2.0",
"axios" => "^1.6.0"
})
else
deps
end
end
defp build_dev_dependencies(features) do
dev_deps = %{"@types/phoenix" => "^1.6.0"}
dev_deps =
if features.typescript do
Map.put(dev_deps, "typescript", "^5.7.2")
else
dev_deps
end
if features.react && features.typescript do
Map.merge(dev_deps, %{
"@types/react" => "^19.1.0",
"@types/react-dom" => "^19.1.0"
})
else
dev_deps
end
end
def update_vendor_imports(igniter) do
features = igniter.assigns[:detected_features]
igniter
|> maybe_update_topbar_imports(features.topbar)
|> maybe_update_daisyui_imports(features.daisyui)
end
defp maybe_update_topbar_imports(igniter, true) do
igniter
|> update_js_for_npm_topbar()
|> remove_vendored_topbar()
end
defp maybe_update_topbar_imports(igniter, false), do: igniter
defp maybe_update_daisyui_imports(igniter, true) do
igniter
|> update_css_for_npm_daisyui()
|> remove_vendored_daisyui()
end
defp maybe_update_daisyui_imports(igniter, false), do: igniter
defp queue_npm_install(igniter) do
case BunIntegration.install_command(igniter) do
nil ->
# Bun handles its own installation
igniter
install_cmd ->
Igniter.add_task(igniter, "cmd", [install_cmd])
end
end
def setup_watcher_for_system_package_managers(igniter) do
# Bun watcher is handled by BunIntegration
if BunIntegration.using_bun?(igniter) do
igniter
else
{igniter, endpoint} = Igniter.Libs.Phoenix.select_endpoint(igniter)
# Use Vite directly with npm/yarn/pnpm
watcher_value =
{:code,
Sourceror.parse_string!("""
["node_modules/.bin/vite", "dev", cd: Path.expand("../assets", __DIR__)]
""")}
Igniter.Project.Config.configure(
igniter,
"dev.exs",
Igniter.Project.Application.app_name(igniter),
[endpoint, :watchers, :node],
watcher_value
)
end
end
def remove_old_watchers(igniter) do
{igniter, endpoint} = Igniter.Libs.Phoenix.select_endpoint(igniter)
app_name = Igniter.Project.Application.app_name(igniter)
# We need to update the watchers configuration by removing specific keys
Igniter.Project.Config.configure(
igniter,
"dev.exs",
app_name,
[endpoint, :watchers],
{:code,
quote do
[]
end},
updater: fn zipper ->
# Remove esbuild and tailwind entries from the keyword list
case Igniter.Code.Keyword.remove_keyword_key(zipper, :esbuild) do
{:ok, zipper} ->
case Igniter.Code.Keyword.remove_keyword_key(zipper, :tailwind) do
{:ok, zipper} -> {:ok, zipper}
:error -> {:ok, zipper}
end
:error ->
{:ok, zipper}
end
end
)
end
def update_root_layout(igniter) do
file_path =
Path.join([
"lib",
web_dir(igniter),
"components",
"layouts",
"root.html.heex"
])
inertia = igniter.args.options[:inertia] || false
react = igniter.args.options[:react] || false
typescript = igniter.args.options[:typescript] || false
# Determine the correct app file extension
app_ext = determine_app_extension(typescript, react, inertia)
# First update the regular root.html.heex regardless of inertia flag
igniter =
igniter
|> Igniter.include_existing_file(file_path)
|> Igniter.update_file(file_path, fn source ->
Rewrite.Source.update(source, :content, fn
content when is_binary(content) ->
if String.contains?(content, "Vitex.vite_") or
String.contains?(content, "Vite.vite_") do
# Already configured
content
else
# Replace Phoenix asset helpers with Vite helpers
updated =
content
# First, try to replace the combined CSS and JS pattern (common in phx.new projects)
|> String.replace(
~r/(\s*)]+href={~p"\/assets\/app\.css"}[^>]*>\s*\n\s*