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)