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)
--yes Don't prompt for confirmations
"""
use Igniter.Mix.Task
require Igniter.Code.Common
@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
],
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
igniter
|> maybe_add_inertia_dep()
|> setup_html_helpers()
|> maybe_setup_inertia()
|> create_vite_config()
|> update_package_json()
|> setup_watcher()
|> remove_old_watchers()
|> update_root_layout()
|> setup_assets()
|> update_mix_aliases()
|> 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
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()
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.Libs.Phoenix.append_to_pipeline(igniter, :browser, "plug Inertia.Plug")
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)
"""
import { defineConfig } from 'vite'
import phoenix from '../deps/vitex/priv/static/vitex'#{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}
})
],#{if options[:typescript], do: "\n resolve: {\n alias: {\n '@': '/js'\n }\n }", else: ""}
})
"""
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 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 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,
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"
}
}
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
Igniter.add_task(igniter, "cmd", ["npm install --prefix assets"])
end
def setup_watcher(igniter) do
{igniter, endpoint} = Igniter.Libs.Phoenix.select_endpoint(igniter)
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
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
if inertia do
# For Inertia, we need to create a separate inertia_root layout
inertia_layout_path =
Path.join([
"lib",
web_dir(igniter),
"components",
"layouts",
"inertia_root.html.heex"
])
content = inertia_root_html(igniter)
igniter
|> Igniter.create_new_file(inertia_layout_path, content, on_exists: :skip)
|> Igniter.add_notice("""
Created Inertia root layout at #{inertia_layout_path}
To use Inertia in your application:
1. Update your router to use the Inertia plug
2. In your controllers, use `render_inertia(conn, "PageName")`
3. Create React components in assets/js/pages/
""")
else
react = igniter.args.options[:react] || false
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
# Pattern 1: Both CSS and JS with ~p sigil (most common in new Phoenix apps)
|> String.replace(
~r/(\s*)]+href={~p"\/assets\/app\.css"}[^>]*>\s*\n\s*