DEV Community

NDREAN
NDREAN

Posted on • Edited on

Phoenix & Vite dev setup

A configuration to use Vite with Phoenix LiveView and pnpm.

mix phx.new --no-assets mix vite.install 
Enter fullscreen mode Exit fullscreen mode

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 native Node.js addons, in which case pnpm 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 
Enter fullscreen mode Exit fullscreen mode

and

app.css -> http://localhost:5173/css/app.css app.js -> http://localhost:5173/js/app.js 
Enter fullscreen mode Exit fullscreen mode

In PROD mode, the Dockerfile should run:

pnpm vite build --mode production --config vite.config.js 
Enter fullscreen mode Exit fullscreen mode

❗️ 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,...} 
Enter fullscreen mode Exit fullscreen mode

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) 
Enter fullscreen mode Exit fullscreen mode

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" /> 
Enter fullscreen mode Exit fullscreen mode

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

Define a config "env" variable:

# config.exs config :my_app, :env, config_env() 
Enter fullscreen mode Exit fullscreen mode
# 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__) ] ] 
Enter fullscreen mode Exit fullscreen mode

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)) 
Enter fullscreen mode Exit fullscreen mode

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> 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

and

app.css -> http://localhost:5173/css/app.css app.js -> http://localhost:5173/js/app.js 
Enter fullscreen mode Exit fullscreen mode

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, }); 
Enter fullscreen mode Exit fullscreen mode

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 }; 
Enter fullscreen mode Exit fullscreen mode

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 ], [...] } }) 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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' 
Enter fullscreen mode Exit fullscreen mode

In the "assets" folder, run:

/assets> pnpm init 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

▶️ 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" } } 
Enter fullscreen mode Exit fullscreen mode

In the root folder, install everything with:

/> pnpm install 
Enter fullscreen mode Exit fullscreen mode

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", } ... } 
Enter fullscreen mode Exit fullscreen mode

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"; 
Enter fullscreen mode Exit fullscreen mode

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] 
Enter fullscreen mode Exit fullscreen mode

-------- 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 
Enter fullscreen mode Exit fullscreen mode

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, }; }); 
Enter fullscreen mode Exit fullscreen mode

Notice that we took advantage of the resolver "@". In your code, you can do:

import { myHook} from "@js/hooks/myHook"; 
Enter fullscreen mode Exit fullscreen mode

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"] 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

and then, for example:

mix vite.install --dep lightweight-charts --dev-dep tailwind-debug-mode 
Enter fullscreen mode Exit fullscreen 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 
Enter fullscreen mode Exit fullscreen mode

Top comments (0)