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)