A configuration to use Vite
with Phoenix LiveView
and pnpm
.
mix phx.new --no-assets
mix vite.install
where the Mix.Task
accepts the flags dep
or dev-dep
, and css
with the value "heroicons" or "daisyui" (copy of Phoenix 1.8
install).
⚠️ It is however recommended to run
pnpm add (-D) ... --prefix assets
as dependencies might need to be compiled such as nativeNode.js
addons, in which casepnpm
will warn you.
The install does:
- setup pnpm-workspace.yaml
- setup package.json and the dev dependencies
- install the client packages
- creates the Vite.ex helper
- creates a config :env giving config_env()
- injects the Vite watcher in dev.exs,
- creates a new root.html.heex with config env dependencies
- creates tow new folders "/assets/seo" and "/assets/icons" populated with placeholders
- modifies
MyAppWeb.static_paths/0
and adds the folder "icons" - creates vite.config.js
It warns you to use Vite.path("path-to-my-static-file")
, which works in DEV and PROD mode.
You can then start the Phoenix
server:
In DEV mode, you should see (at least) two WebSocket:
ws://localhost:4000/phoenix/live_reload/socket/websocket?vsn=2.0.0
ws://localhost:5173/?token=yFGCVgkhJxQg
and
app.css -> http://localhost:5173/css/app.css
app.js -> http://localhost:5173/js/app.js
In PROD mode, the Dockerfile should run:
pnpm vite build --mode production --config vite.config.js
❗️ Leftover. The file app.css (and daisyui
and heroicons
) is not setup unless you pass the option css
.
How? The documentation: https://vite.dev/guide/backend-integration.html
Why? You can easily bring in plugins such as VitePWA with Workbox, or ZSTD compression and more.
What? In DEV mode, you will be running a Vite
dev server on port 5173 and Phoenix
on port 4000.
Static assets
All your static assets should be organised in the "/assets" folder with the structure:
/assets/{js,css,seo,fonts,icons,images,wasm,...}
Do not add anything in the "/priv/static" folder as it will be pruned but instead place in the "/assets" folder.
In DEV mode, the vite.config.js settings will copy the non-fingerprinted files into "/priv/static". All the other assets (fingerprinted) will remain in the "/assets/{js,images,...}" folders and served by Vite
.
For example, you have non-fingerprinted assets such as robots.txt and sitemap.xml. You place them in "/assets/seo" and these files will by copied in the "priv/static" folder and will be served by Phoenix.
For example, all your icons, eg favicons, should be placed in the folder "assets/icons" and will be copied in "priv/static/icons".
These files are served by Phoenix as declared in static_paths/0
:
def static_paths, do: ~w(
assets
icons
robots.txt
sw.js
manifest.webmanifest
sitemap.xml)
The other - fingerprinted - static assets should use the Elixir module Vite.path/1
.
In DEV mode, it will prepend http://localhost:5173 to the file name.
For example, set src={Vite.path("js/app.js")}
so Vite
will serve it at http://localhost:5173/js/app.js.
Another example; suppose you have a Phoenix.Component named techs.ex where you display some - fingerprinted - images:
<img src={Vite.path("images/my.svg"} alt="an-svg" loading="lazy" />
These images are placed in the folder "assets/images".
For non-fingerprpinted assets, such as those placed in "/assets/icons", eg your favorite favicon.ico, then just reference it in the layout root.html.heex with:
<link rel="icon" href="icons/favicon.ico" type="image/png" sizes="48x48" />
In PROD mode, these "app.js" or "my.svg" files will have a hashed name.
The Vite.path/1
will look into the .vite/manifest.json file generated by Vite
.
At compile time, it will bundle the files with the correct fingerprinted name into the folder "/priv/static/assets" so Phoenix
will serve them.
Note that this also means you do not need mix phx.digest
anymore in the build stage.
- Phoenix dev.exs config
- Root layout
- Vite Config: server and build options
- Package.json
- Tailwind, daisyui and heroicons
- An Elixir file path resolving module
- Vite.config.js
- Dockerfile
- Mix Install Task
Phoenix dev.exs config
Define a config "env" variable:
# config.exs
config :my_app, :env, config_env()
# dev.exs
config :my_app, MyAppWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4000],
[...],
code_reloader: true,
live_reload: [
web_console_logger: true,
patterns: [
~r"lib/my_app_web/(controllers|live|components|channels)/.*(ex|heex)$",
~r"lib/my_app/.*(ex)$"
]
],
watchers: [
pnpm: [
"vite",
"serve",
"--mode",
"development",
"--config",
"vite.config.js",
cd: Path.expand("../assets", __DIR__)
]
]
Root layout
Pass the assign @env
in the LiveView (or controller), or use Application.get_env(:my_app, :env)
:
|> assign(:env, Application.fetch_env!(:my_app, :env))
Add the following to "root.html.heex":
# root.html.heex
<link
:if={Application.get_env(:my_app, :env) === :prod}
rel="stylesheet"
href={Vite.path("css/app.css")}
/>
<script
:if={@env === :dev}
type="module"
src="http://localhost:5173/@vite/client"
>
</script>
<script
defer
type="module"
src={Vite.path("js/app.js")}
>
</script>
When you run the app, you can inspect the "network" tab and should get (at least) the two WebSocket connections:
ws://localhost:4000/phoenix/live_reload/socket/websocket?vsn=2.0.0
ws://localhost:5173/?token=yFGCVgkhJxQg
and
app.css -> http://localhost:5173/css/app.css
app.js -> http://localhost:5173/js/app.js
Vite Config, server and build options
Build options
const staticDir = "../priv/static";
const buildOps = (mode) => ({
target: ["esnext"],
// the directory to nest generated assets under (relative to build.outDir)
outDir: staticDir,
rollupOptions: {
input: mode == "production" ? getEntryPoints() : ["./js/app.js"],
output: mode === "production" && {
assetFileNames: "assets/[name]-[hash][extname]",
chunkFileNames: "assets/[name]-[hash].js",
entryFileNames: "assets/[name]-[hash].js",
},
},
// generate a manifest file that contains a mapping
// of non-hashed asset filenames in PROD mode
manifest: mode === "production",
path: ".vite/manifest.json",
minify: mode === "production",
emptyOutDir: true, // Remove old assets
sourcemap: mode === "development" ? "inline" : true,
reportCompressedSize: true,
assetsInlineLimit: 0,
});
The getEntryPoints()
function is detailed in the vite.config.js part. It is a list of all your files that will be fingerprinted.
The other static assets should by copied with the plugin viteStaticCopy
to which we pass a list of objects (source, destination). These are eg SEO files (robots.txt, sitemap.xml), and your icons, fonts ...
Server options
// vite.config.js
const devServer = {
cors: { origin: "http://localhost:4000" },
allowedHosts: ["localhost"],
strictPort: true,
origin: "http://localhost:5173", // Vite dev server origin
port: 5173, // Vite dev server port
host: "localhost", // Vite dev server host
};
The vite.config.js module will export:
export default defineConfig = ({command, mode}) => {
if (command == 'serve') {
process.stdin.on('close', () => process.exit(0));
copyStaticAssetsDev(); //<- see below
process.stdin.resume();
}
return {
server: mode === 'development' && devServer,
build: buildOps(mode),
publicDir: false,
plugins: [
tailwindcss(),
viteStaticCopy(...) //<- see below
],
[...]
}
})
Run a separate Vite dev server in DEBUG mode
You can also run the dev server in a separate terminal in DEBUG mode.
In this case, remove the watcher above, and run:
DEBUG=vite:* pnpm vite serve
The DEBUG=vite:*
option gives extra informations that can be useful even if it may seem verbose.
Package.json
Using workspace with pnpm
You can use pnpm
with workspaces. In the root folder, define a "pnpm-workspace.yaml" file (❗️not "yml") and reference your "assets" folder and the "deps" folder (for Phoenix.js
):
# /pnpm-workspace.yaml
packages:
- assets
- deps/phoenix
- deps/phoenix_html
- deps/phoenix_live_view
ignoreBuildDependencies:
- esbuild
onlyBuiltDependencies:
- '@tailwindcss/oxide'
In the "assets" folder, run:
/assets> pnpm init
and populate your newly created package.json with your favourite client dependencies:
/assets> pnpm add -D tailwindcss @tailwindcss/vite daisyui vite-plugin-static-copy fast-glob lightningcss
▶️ Set "type": "module"
▶️ Set "name": "assets"
▶️ use "workspace" to reference Phoenix dependencies
# /assets/package.json
{
"type": "module",
"name": "assets",
"dependencies": {
"phoenix": "workspace:*",
"phoenix_html": "workspace:*",
"phoenix_live_view": "workspace:*",
"topbar": "^3.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.11",
"daisyui": "^5.0.43",
"tailwindcss": "^4.1.11",
"vite": "^7.0.0",
"vite-plugin-static-copy": "^2.3.1",
"fast-glob": "^3.3.3",
"lightningcss": "^1.30.1"
}
}
In the root folder, install everything with:
/> pnpm install
without workspace
Alternatively, if you don't use workspace, then reference directly the relative location for the Phoenix dependencies.
{
"type": "module",
"dependencies": {
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
}
...
}
Tailwind, daisyui and heroicons
▶️ cf Phoenix 1.8
: disable automatic source detection and instead specify sources explicitely.
# /assets/css/app.css
@import 'tailwindcss' source(none);
@source "../css";
@source "../**/.*{js, jsx}";
@source "../../lib/my_app_web/";
@plugin "daisyui";
@plugin "../vendor/heroicons.js";
where the "assets/vendor/heroicons.js" file is (from phoenix
1.8):
An Elixir file path resolving module
This is needed to resolve the file path in dev or in prod mode.
❗️You need to change your application name in this module
The mix task uses the file below as a template and injects:
app_name = Mix.Project.config()[:app]
-------- Vite.ex --------
defmodule Vite do
@moduledoc """
Helper for Vite assets paths in development and production.
"""
def path(asset) do
case Application.get_env(:my_app, :env) do
:dev -> "http://localhost:5173/" <> asset
_ -> get_production_path(asset)
end
end
defp get_production_path(asset) do
manifest = get_manifest(:my_app)
case Path.extname(asset) do
".css" -> get_main_css_in(manifest)
_ -> get_asset_path(manifest, asset)
end
end
defp get_manifest(app_name) do
manifest_path = Path.join(:code.priv_dir(app_name), "static/.vite/manifest.json")
with {:ok, content} <- File.read(manifest_path),
{:ok, decoded} <- Jason.decode(content) do
decoded
else
_ -> raise "Could not read Vite manifest at #{manifest_path}"
end
end
defp get_main_css_in(manifest) do
manifest
|> Enum.flat_map(fn {_key, entry} -> Map.get(entry, "css", []) end)
|> Enum.find(&String.contains?(&1, "app"))
|> case do
nil -> raise "Main CSS file not found in manifest"
file -> "/#{file}"
end
end
defp get_asset_path(manifest, asset) do
case manifest[asset] do
%{"file" => file} -> "/#{file}"
_ -> raise "Asset #{asset} not found in manifest"
end
end
end
Vite.config.js
Your "vite.config.js" file is placed in the "assets" folder.
You locate all your assets in this "assets" folder, with a structure like:
assets/{js, css, icons, images, wasm, fonts, seo}
This Vite
file will copy and build the necessary files for you (given the structure above):
-------- vite.config.js --------
// /assets/vite.config.js
import { defineConfig } from "vite";
import fs from "fs"; // for file system operations
import path from "path";
import fg from "fast-glob"; // for recursive file scanning
import tailwindcss from "@tailwindcss/vite";
import { viteStaticCopy } from "vite-plugin-static-copy";
const rootDir = path.resolve(import.meta.dirname);
const cssDir = path.resolve(rootDir, "css");
const jsDir = path.resolve(rootDir, "js");
const seoDir = path.resolve(rootDir, "seo");
const iconsDir = path.resolve(rootDir, "icons");
const srcImgDir = path.resolve(rootDir, "images");
const staticDir = path.resolve(rootDir, "../priv/static");
/*
PROD mode: list of fingerprinted files to pass to RollUp(/Down)
*/
const getEntryPoints = () => {
const entries = [];
fg.sync([`${jsDir}/**/*.{js,jsx,ts,tsx}`]).forEach((file) => {
if (/\.(js|jsx|ts|tsx)$/.test(file)) {
entries.push(path.resolve(rootDir, file));
}
});
fg.sync([`${srcImgDir}/**/*.*`]).forEach((file) => {
if (/\.(jpg|png|svg|webp)$/.test(file)) {
entries.push(path.resolve(rootDir, file));
}
});
return entries;
};
const buildOps = (mode) => ({
target: ["esnext"],
outDir: staticDir,
rollupOptions: {
input:
mode == "production" ? getEntryPoints() : ["./js/app.js"],
// hash only in production mode
output: mode === "production" && {
assetFileNames: "assets/[name]-[hash][extname]",
chunkFileNames: "assets/[name]-[hash].js",
entryFileNames: "assets/[name]-[hash].js",
},
},
manifest: mode === 'production',
path: ".vite/manifest.json",
minify: mode === "production",
emptyOutDir: true, // Remove old assets
sourcemap: mode === "development" ? "inline" : true,
});
/*
Static assets served by Phoenix via the plugin `viteStaticCopy`
=> add other folders like assets/fonts...if needed
*/
// -- DEV mode: copy non-fingerprinted files to priv/static --
function copyStaticAssetsDev() {
console.log("[vite.config] Copying non-fingerprinted assets in dev mode...");
const copyTargets = [
{
srcDir: seoDir,
destDir: staticDir, // place directly into priv/static
},
{
srcDir: iconsDir,
destDir: path.resolve(staticDir, "icons"),
},
];
copyTargets.forEach(({ srcDir, destDir }) => {
if (!fs.existsSync(srcDir)) {
console.log(`[vite.config] Source dir not found: ${srcDir}`);
return;
}
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
fg.sync(`${srcDir}/**/*.*`).forEach((srcPath) => {
const relPath = path.relative(srcDir, srcPath);
const destPath = path.join(destDir, relPath);
const destSubdir = path.dirname(destPath);
if (!fs.existsSync(destSubdir)) {
fs.mkdirSync(destSubdir, { recursive: true });
}
fs.copyFileSync(srcPath, destPath);
});
});
}
// -- PROD mode: config for viteStaticCopy --
const getBuildTargets = () => {
const baseTargets = [];
// Only add targets if source directories exist
if (fs.existsSync(seoDir)) {
baseTargets.push({
src: path.resolve(seoDir, "**", "*"),
dest: path.resolve(staticDir),
});
}
if (fs.existsSync(iconsDir)) {
baseTargets.push({
src: path.resolve(iconsDir, "**", "*"),
dest: path.resolve(staticDir, "icons"),
});
}
const devManifestPath = path.resolve(staticDir, "manifest.webmanifest");
if (fs.existsSync(devManifestPath)) {
fs.writeFileSync(devManifestPath, JSON.stringify(manifestOpts, null, 2));
};
return baseTargets;
};
const resolveConfig = {
alias: {
"@": rootDir,
"@js": jsDir,
"@jsx": jsDir,
"@css": cssDir,
"@static": staticDir,
"@assets": srcImgDir,
},
extensions: [".js", ".jsx", "png", ".css", "webp", "jpg", "svg"],
};
const devServer = {
cors: { origin: "http://localhost:4000" },
allowedHosts: ["localhost"],
strictPort: true,
origin: "http://localhost:5173", // Vite dev server origin
port: 5173, // Vite dev server port
host: "localhost", // Vite dev server host
watch: {
ignored: ["**/priv/static/**", "**/lib/**", "**/*.ex", "**/*.exs"],
},
};
export default defineConfig(({ command, mode }) => {
if (command == "serve") {
console.log("[vite.config] Running in development mode");
copyStaticAssetsDev();
process.stdin.on("close", () => process.exit(0));
process.stdin.resume();
}
return {
base: "/",
plugins: [
viteStaticCopy({ targets: getBuildTargets() }),
tailwindcss(),
],
resolve: resolveConfig,
// Disable default public dir (using Phoenix's)
publicDir: false,
build: buildOps(mode),
server: mode === "development" && devServer,
};
});
Notice that we took advantage of the resolver "@". In your code, you can do:
import { myHook} from "@js/hooks/myHook";
Dockerfile
To build for production, you will run pnpm vite build
.
In the Dockerfile below, we use the "workspace" version:
-------- Dockerfile --------
# Stage 1: Build
ARG ELIXIR_VERSION=1.18.3
ARG OTP_VERSION=27.3.4
ARG DEBIAN_VERSION=bullseye-20250428-slim
ARG pnpm_VERSION=10.12.4
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
ARG MIX_ENV=prod
ARG NODE_ENV=production
FROM ${BUILDER_IMAGE} AS builder
RUN apt-get update -y && apt-get install -y \
build-essential git curl && \
curl -sL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs && \
apt-get clean && rm -f /var/lib/apt/lists/*_*
ARG MIX_ENV
ARG NODE_ENV
ENV MIX_ENV=${MIX_ENV}
ENV NODE_ENV=${NODE_ENV}
# Install pnpm
RUN corepack enable && corepack prepare pnpm@${pnpm_VERSION} --activate
# Prepare build dir
WORKDIR /app
# Install Elixir deps
RUN mix local.hex --force && mix local.rebar --force
COPY mix.exs mix.lock pnpm-lock.yaml pnpm-workspace.yaml ./
RUN mix deps.get --only ${MIX_ENV}
RUN mkdir config
# compile Elxirr deps
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile
# compile Node deps
WORKDIR /app/assets
COPY assets/package.json ./
WORKDIR /app
RUN pnpm install --frozen-lockfile
# Copy app server code before building the assets
# since the server code may contain Tailwind code.
COPY lib lib
# Copy, install & build assets--------
COPY priv priv
# this will copy the assets/.env for the Maptiler api key loaded by Vite.loadenv
WORKDIR /app/assets
COPY assets ./
RUN pnpm vite build --mode ${NODE_ENV} --config vite.config.js
WORKDIR /app
# RUN mix phx.digest <-- used Vite to fingerprint assets instead
RUN mix compile
COPY config/runtime.exs config/
# Build the release-------
COPY rel rel
RUN mix release
# Stage 2: Runtime --------------------------------------------
FROM ${RUNNER_IMAGE}
RUN apt-get update -y && \
apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
ENV MIX_ENV=prod
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8
WORKDIR /app
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/liveview_pwa ./
# <-- needed for local testing
RUN chown -R nobody:nogroup /mnt
RUN mkdir -p /app/db && \
chown -R nobody:nogroup /app/db && \
chmod -R 777 /app/db && \
chown nobody /app
USER nobody
EXPOSE 4000
CMD ["/bin/sh", "-c", "/app/bin/server"]
Mix install task
This task uses templates, the files above, to prepare the code.
Check it here: https://github.com/dwyl/phx_vite/tree/main/lib/mix/tasks
Normally, you run:
mix phx.new my_app --no-assets
and then, for example:
mix vite.install --dep lightweight-charts --dev-dep tailwind-debug-mode
The detail of the task:
defmodule Mix.Tasks.Vite.Install do
use Mix.Task
import Mix.Generator
@moduledoc """
Installs and configures Vite for Phoenix LiveView projects.
Sets up a complete Vite-based asset pipeline with Tailwind CSS, pnpm workspace,
and generates helper modules for development and production asset handling.
## Usage
$ mix vite.install
$ mix vite.install --dep alpinejs --dev-dep postcss
## Options
* `--dep` - Add a regular dependency (can be used multiple times)
* `--dev-dep` - Add a development dependency (can be used multiple times)
## Examples
$ mix vite.install --dep react --dep lodash
$ mix vite.install --dev-dep sass --dev-dep autoprefixer
"""
@shortdoc "Installs and configures Vite for Phoenix projects"
@impl Mix.Task
def run(args) do
case System.find_executable("pnpm") do
nil ->
Mix.shell().error("pnpm is not installed. Please install pnpm to continue.")
Mix.raise("Missing dependency: pnpm")
_ ->
:ok
end
# Parse command line arguments. :keep allows multiple values
# Note: Use hyphens in CLI arguments (--dev-dep), not underscores
# (e.g., mix vite.install --dep topbar --dev-dep @types/node)
{opts, _, _} =
OptionParser.parse(args, switches: [dep: :keep, dev_dep: :keep], aliases: [d: :dep])
extra_deps = Keyword.get_values(opts, :dep)
extra_dev_deps = Keyword.get_values(opts, :dev_dep)
%{app_name: app_name, app_module: app_module} = context()
Mix.shell().info("Assets setup started for #{app_name} (#{app_module})...")
Mix.shell().info("Extra dependencies to install: #{Enum.join(extra_deps, ", ")}")
if extra_dev_deps != [] do
Mix.shell().info("Extra dev dependencies to install: #{Enum.join(extra_dev_deps, ", ")}")
end
# Add topbar by default unless --no-topbar is specified
extra_deps = extra_deps ++ ["topbar"]
# Setup pnpm workspace and install all dependencies
setup_pnpm_workspace(extra_deps, extra_dev_deps)
setup_install_deps()
# Create asset directories and placeholder files
setup_asset_directories()
# Update static_paths to include icons
update_static_paths(app_name)
# Add config first before generating files that depend on it
append_to_file("config/config.exs", config_template(context()))
create_file("lib/#{app_name}_web/vite.ex", vite_helper_template(context()))
create_file(
"lib/#{app_name}_web/components/layouts/root.html.heex",
root_layout_template(context())
)
create_file("assets/vite.config.js", vite_config_template())
append_to_file("config/dev.exs", vite_watcher_template(context()))
Mix.shell().info("Assets installation completed!")
Mix.shell().info("")
Mix.shell().info("✅ What was added to your project:")
Mix.shell().info(" • Environment config in config/config.exs")
Mix.shell().info(" • Vite watcher configuration in config/dev.exs")
Mix.shell().info(" • Vite configuration file at assets/vite.config.js")
Mix.shell().info(
" • Updated root layout template at lib/#{app_name}_web/components/layouts/root.html.heex"
)
Mix.shell().info(" • Vite helper module at lib/#{app_name}_web/vite.ex")
Mix.shell().info(" • pnpm workspace configuration at pnpm-workspace.yaml")
Mix.shell().info(" • Package.json with Phoenix workspace dependencies")
Mix.shell().info(
" • Asset directories: assets/icons/ and assets/seo/ with placeholder files"
)
Mix.shell().info(" • Updated static_paths in lib/#{app_name}_web.ex to include 'icons'")
Mix.shell().info(" • Client libraries: #{Enum.join(extra_deps, ", ")}")
Mix.shell().info(" • Dev dependencies: Tailwind CSS, Vite, DaisyUI, and build tools")
Mix.shell().info("")
Mix.shell().info("🚀 Next steps:")
Mix.shell().info(" • Check 'static_paths/0' in your endpoint config")
Mix.shell().info(" • Use 'Vite.path/1' in your code to define the source of your assets")
Mix.shell().info(" • Run 'mix phx.server' to start your Phoenix server")
Mix.shell().info(" • Vite dev server will start automatically on http://localhost:5173")
end
defp context() do
# Get application name from mix.exs
app_name = Mix.Project.config()[:app]
app_module = Mix.Project.config()[:app] |> Atom.to_string() |> Macro.camelize()
%{
app_name: app_name,
app_module: app_module,
web_module: "#{app_module}Web"
}
end
defp setup_pnpm_workspace(extra_deps, extra_dev_deps) do
{v, _} = System.cmd("pnpm", ["-v"])
version = String.trim(v)
workspace_content = """
packages:
- assets
- deps/phoenix
- deps/phoenix_html
- deps/phoenix_live_view
ignoredBuiltDependencies:
- esbuild
onlyBuiltDependencies:
- '@tailwindcss/oxide'
"""
# Build dependencies object for package.json
base_deps = %{
"phoenix" => "workspace:*",
"phoenix_html" => "workspace:*",
"phoenix_live_view" => "workspace:*"
}
# Add extra dependencies
deps_map =
Enum.reduce(extra_deps, base_deps, fn dep, acc ->
Map.put(acc, dep, "latest")
end)
# Build dev dependencies
base_dev_dependencies = [
"@tailwindcss/oxide",
"@tailwindcss/vite",
"@tailwindcss/forms",
"@tailwindcss/typography",
"daisyui",
"fast-glob",
"tailwindcss",
"vite",
"vite-plugin-static-copy"
]
all_dev_deps = base_dev_dependencies ++ extra_dev_deps
dev_deps_map =
Enum.reduce(all_dev_deps, %{}, fn dep, acc ->
Map.put(acc, dep, "latest")
end)
# Create package.json with all dependencies
package_json = %{
"type" => "module",
"dependencies" => deps_map,
"devDependencies" => dev_deps_map,
"packageManager" => "pnpm@#{version}"
}
File.write!("./pnpm-workspace.yaml", workspace_content)
File.write!("./assets/package.json", Jason.encode!(package_json, pretty: true))
{:ok, _} = File.rm_rf("./assets/node_modules")
{:ok, _} = File.rm_rf("./node_modules")
Mix.shell().info("Dependencies to install: #{length(extra_deps)} packages")
Mix.shell().info("Dev dependencies to install: #{length(all_dev_deps)} packages")
end
defp setup_install_deps() do
Mix.shell().info("Installing all dependencies with pnpm...")
case System.cmd("pnpm", ["install"]) do
{output, 0} ->
Mix.shell().info("Assets installed successfully")
Mix.shell().info(output)
{error_output, _exit_code} ->
Mix.shell().error("Failed to install assets: #{error_output}")
end
end
defp setup_asset_directories() do
# Create icons directory and copy favicon.ico from templates
File.mkdir_p!("./assets/icons")
favicon_source = Path.join([__DIR__, "templates", "favicon.ico"])
File.cp!(favicon_source, "./assets/icons/favicon.ico")
Mix.shell().info("Created assets/icons/ directory with favicon.ico")
# Create SEO directory and copy robots.txt from templates, create empty sitemap.xml
File.mkdir_p!("./assets/seo")
robots_source = Path.join([__DIR__, "templates", "robots.txt"])
File.cp!(robots_source, "./assets/seo/robots.txt")
File.write!("./assets/seo/sitemap.xml", "")
Mix.shell().info("Created assets/seo/ directory with robots.txt and sitemap.xml")
end
# Template functions using EEx
defp vite_helper_template(assigns) do
read_template("vite_helper.ex.eex")
|> EEx.eval_string(assigns: assigns)
end
defp vite_watcher_template(assigns) do
("\n\n" <> read_template("vite_watcher.exs.eex"))
|> EEx.eval_string(assigns: assigns)
end
defp config_template(assigns) do
("\n\n" <> read_template("config.exs.eex"))
|> EEx.eval_string(assigns: assigns)
end
defp root_layout_template(assigns) do
read_template("root_layout.html.eex")
|> EEx.eval_string(assigns: assigns)
end
defp vite_config_template() do
read_template("vite.config.js")
end
defp read_template(filename) do
template_path = Path.join([__DIR__, "templates", filename])
File.read!(template_path)
end
defp update_static_paths(app_name) do
web_file_path = "lib/#{app_name}_web.ex"
content = File.read!(web_file_path)
if String.contains?(content, "icons") do
Mix.shell().info("#{web_file_path} already includes 'icons' in static_paths")
else
updated_content = String.replace(content, ~r/~w\(/, "~w(icons ")
if updated_content != content do
File.write!(web_file_path, updated_content)
Mix.shell().info("Updated #{web_file_path} to include 'icons' in static_paths")
end
end
end
defp append_to_file(path, content) do
existing_content = File.read!(path)
# Extract just the config line to check for (remove comments)
config_line =
content
|> String.split("\n")
|> Enum.find(&String.contains?(&1, "config :"))
# Check if the specific config already exists
if config_line && String.contains?(existing_content, String.trim(config_line)) do
Mix.shell().info("#{path} already contains the configuration, skipping...")
else
case File.write(path, content, [:append]) do
:ok -> Mix.shell().info("Updated #{path}")
{:error, reason} -> Mix.shell().error("Failed to update #{path}: #{reason}")
end
end
end
end
Top comments (0)