shahryarjb

shahryarjb

How to Prevent Duplicate CSS When Merging Tailwind 4 Output at Build and Runtime?

Hi. Recently, I’ve been building a page builder in Phoenix that runs in the runtime environment and actively accepts Tailwind code (Runtime compiling module and assets).
I’ve recently been working on implementing a system that allows me to merge the Tailwind CSS classes generated at build time with additional CSS classes generated at runtime using the Tailwind binary. (I am using Tailwind 4)

Unfortunately, the issue I’m facing is CSS duplication in my source code, which increases the overall size of the output.


In the development environment, the source code is fully accessible, and Tailwind can easily rescan the modules and transfer all the classes into the CSS file. However, after building the Phoenix project’s release, this capability is no longer available (or maybe i do not know how can scan the html part in production mode).


Now, based on the approach used by Beacon, I store the modules that I dynamically create at runtime in a .template text file and pass them to the Tailwind CLI. Tailwind then processes and compiles the CSS accordingly.

Everything works, but the only issue is that we also need the CSS that was originally compiled into Phoenix’s app.css during compile time and placed in the priv directory. Since both include Tailwind imports, we end up with a lot of duplicated styles.

What do you suggest?

This is simple Tailwind module that I use with Phoenix 1.8 and Tailwind 4

Gist: tailwind_compiler.ex · GitHub

defmodule MishkaCms.Runtime.Compilers.TailwindCompiler do
  @moduledoc """
  Production-ready Tailwind 4 compiler for runtime CSS generation.
  Creates sample templates and compiles them to CSS using Tailwind CLI.
  """

  require Logger

  @doc """
  Compiles Tailwind CSS from hardcoded templates.
  Returns {:ok, css} or {:error, reason}
  """
  def compile do
    # Use :code.priv_dir for production compatibility
    tmp_dir = get_temp_dir()

    # Ensure directory exists (important for releases)
    _ = File.mkdir_p(tmp_dir)

    try do
      # Generate sample template files
      _ = generate_sample_templates!(tmp_dir)

      # Run Tailwind compilation
      output_css = execute_tailwind()

      {:ok, output_css}
    rescue
      e -> {:error, Exception.message(e)}
    after
      # Cleanup templates but keep the directory
      cleanup_templates(tmp_dir)
    end
  end

  defp get_temp_dir do
    # In production/release: uses the app's priv directory
    # In development: falls back to relative path
    case :code.priv_dir(:mishka_cms) do
      {:error, _} -> Path.join(["priv", "static", "temp"])
      priv_dir -> Path.join([priv_dir, "static", "temp"])
    end
  end

  defp generate_sample_templates!(tmp_dir) do
    templates = [
      {"sample_1.template",
       """
       <div>
         <h1 id="header1" class="text-center w-full bg-gray-200 p-4 border border-gray-300 rounded-md shadow-md">
           <%= @page_data.page.title %>
         </h1>
         <p class="mishka-test">Count: <%= @page_data.extra.count || 0 %></p>
         <br />
         <button
           phx-click={JS.toggle_class("text-red-500", to: "#header1")}
           class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
         >
           Change Color
         </button>
       </div>
       """}
      # {"sample_2.template",
      #  """
      #  <nav class="bg-gray-800 p-4">
      #    <div class="flex items-center justify-between">
      #      <span class="text-white text-xl font-semibold">My App</span>
      #      <div class="flex space-x-4">
      #        <a href="#" class="text-gray-300 hover:text-white transition-colors">Home</a>
      #        <a href="#" class="text-gray-300 hover:text-white transition-colors">About</a>
      #        <a href="#" class="text-gray-300 hover:text-white transition-colors">Contact</a>
      #      </div>
      #    </div>
      #  </nav>
      #  """},
      # {"sample_3.template",
      #  """
      #  <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-6">
      #    <div class="bg-white rounded-lg shadow-md p-6">
      #      <h2 class="text-xl font-semibold mb-2">Card Title</h2>
      #      <p class="text-gray-600">Card content goes here.</p>
      #    </div>
      #    <div class="bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-lg p-6">
      #      <h2 class="text-xl font-semibold mb-2">Gradient Card</h2>
      #      <p>Another card with gradient background.</p>
      #    </div>
      #  </div>
      #  """},
      # {"sample_4.template",
      #  """
      #  <form class="max-w-md mx-auto mt-8 space-y-4">
      #    <input type="text" class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="Enter your name">
      #    <textarea class="w-full px-4 py-2 border border-gray-300 rounded-md resize-none focus:ring-2 focus:ring-blue-500" rows="4" placeholder="Your message"></textarea>
      #    <button type="submit" class="w-full bg-green-600 hover:bg-green-700 text-white font-medium py-2 px-4 rounded-md transition-colors">
      #      Submit
      #    </button>
      #  </form>
      #  """}
    ]

    Enum.map(templates, fn {filename, content} ->
      filepath = Path.join(tmp_dir, filename)
      File.write!(filepath, content)
      filepath
    end)
  end

  defp execute_tailwind do
    # Create temporary input CSS file with dynamic content
    timestamp = :os.system_time(:millisecond)
    input_css_path = Path.join(System.tmp_dir!(), "tailwind_input_#{timestamp}.css")
    output_css_path = Path.join(System.tmp_dir!(), "tailwind_output_#{timestamp}.css")

    # Write CSS to temporary file
    _ = File.write!(input_css_path, build_css_content())

    try do
      # Check if Tailwind binary exists, install if not
      if !File.exists?(Tailwind.bin_path()) do
        Logger.info("Tailwind binary not found at #{Tailwind.bin_path()}. Installing...")
        Tailwind.install()
      end

      # Build arguments for Tailwind CLI
      # Always minify in production-ready code
      # args = ["--input=#{input_css_path}", "--output=#{output_css_path}", "--minify"]
      args = ["--input=#{input_css_path}", "--output=#{output_css_path}"]

      # Run Tailwind CLI
      case System.cmd(Tailwind.bin_path(), args, cd: File.cwd!(), stderr_to_stdout: true) do
        {_output, 0} ->
          File.read!(output_css_path)

        {error_output, exit_code} ->
          raise """
          Tailwind compilation failed with exit code #{exit_code}

          Output: #{error_output}

          Tailwind path: #{Tailwind.bin_path()}
          Args: #{inspect(args)}
          """
      end
    after
      # Cleanup temporary files
      File.rm(input_css_path)
      File.rm(output_css_path)
    end
  end

  defp cleanup_templates(tmp_dir) do
    case File.ls(tmp_dir) do
      {:ok, files} ->
        files
        |> Enum.filter(&String.ends_with?(&1, ".template"))
        |> Enum.each(&File.rm!(Path.join(tmp_dir, &1)))

      {:error, _} ->
        # Directory doesn't exist, nothing to clean
        :ok
    end
  end

  @doc """
  Compiles and stores the CSS in ETS.
  Creates an ETS table if it doesn't exist.
  """
  def compile_and_store(ets_table_name \\ :tailwind_css_cache) do
    # Ensure ETS table exists
    if :ets.info(ets_table_name) == :undefined do
      :ets.new(ets_table_name, [:set, :public, :named_table])
    end

    case compile() do
      {:ok, css} ->
        :ets.insert(ets_table_name, {:compiled_css, css})
        Logger.info("Tailwind CSS compiled and stored in ETS")
        {:ok, css}

      {:error, reason} = error ->
        Logger.error("Failed to compile Tailwind CSS: #{reason}")
        error
    end
  end

  @doc """
  Retrieves compiled CSS from ETS.
  """
  def get_compiled_css(ets_table_name \\ :tailwind_css_cache) do
    case :ets.lookup(ets_table_name, :compiled_css) do
      [{:compiled_css, css}] -> css
      [] -> "/* CSS not found for this site */"
    end
  end

  defp build_css_content do
    temp_dir = get_temp_dir()
    app_css_path = Path.join([:code.priv_dir(:mishka_cms), "static", "assets", "css", "app.css"])

    _existing_css = if File.exists?(app_css_path), do: File.read!(app_css_path), else: ""

    """
    /* Import Tailwind layers once */
    @import "tailwindcss" source(none);

    /* Actually import your custom base app styles */
    @import "#{app_css_path}";

    /* Also scan app.css and templates for utility classes */
    @source "#{app_css_path}";
    @source "#{temp_dir}/*.template";
    """
  end
end

As you can see in the code below, if I don’t call @import "tailwindcss" source(none); again here, the styles—such as colors—won’t compile correctly at runtime. But if I do include it, I end up with duplicate CSS rules.

I place the runtime CSS in ETS and include it in root.html.

  defp build_css_content do
    temp_dir = get_temp_dir()
    app_css_path = Path.join([:code.priv_dir(:mishka_cms), "static", "assets", "css", "app.css"])

    _existing_css = if File.exists?(app_css_path), do: File.read!(app_css_path), else: ""

    """
    /* Import Tailwind layers once */
    @import "tailwindcss" source(none);

    /* Actually import your custom base app styles */
    @import "#{app_css_path}";

    /* Also scan app.css and templates for utility classes */
    @source "#{app_css_path}";
    @source "#{temp_dir}/*.template";
    """
  end

Note: if i do not import @import "#{app_css_path}"; again here the custom css which is written by developer is not brought! and does not work

Thank you in advance

Another discussions

First Post!

shahryarjb

shahryarjb

I’m thinking about copying the contents of some directories at compile time and converting them into a text file so that I can access them at runtime. But this approach takes up a lot of space in the codebase and increases compile time. Or get all class with a regex and write in a file and put in priv dir

Do you think there’s another possible way to access content that contains Tailwind classes at runtime?

Something like this (generated by AI)

defmodule Mix.Tasks.ExtractTailwindClasses do
  use Mix.Task

  @shortdoc "Extract Tailwind classes from Elixir modules and write to file"

  def run(_args) do
    classes = extract_classes()
    write_classes_to_file(classes)

    IO.puts("✅ Extracted #{length(classes)} classes to priv/static/temp/tailwind_classes.txt")
  end

  defp extract_classes do
    # Scan all Elixir files
    Path.wildcard("lib/**/*.{ex,exs,heex}")
    |> Enum.flat_map(&extract_from_file/1)
    |> Enum.uniq()
    |> Enum.sort()
  end

  defp extract_from_file(file_path) do
    case File.read(file_path) do
      {:ok, content} -> extract_from_content(content)
      {:error, _} -> []
    end
  end

  defp extract_from_content(content) do
    patterns = [
      # class="..."
      ~r/class\s*=\s*"([^"]+)"/,
      # class='...'
      ~r/class\s*=\s*'([^']+)'/,
      # class: "..."
      ~r/class:\s*"([^"]+)"/,
      # class: '...'
      ~r/class:\s*'([^']+)'/,
      # class=~s[...]
      ~r/class\s*=\s*~s\[([^\]]+)\]/,
      # class="""..."""
      ~r/class\s*=\s*"""([^"]+)"""/
    ]

    patterns
    |> Enum.flat_map(fn pattern ->
      Regex.scan(pattern, content, capture: :all_but_first)
      |> List.flatten()
    end)
    |> Enum.map(&normalize_class_string/1)
    |> Enum.reject(&(&1 == "" or is_nil(&1)))
  end

  defp normalize_class_string(class_string) do
    class_string
    |> String.trim()
    |> String.replace(~r/\s+/, " ")
  end

  defp write_classes_to_file(classes) do
    File.mkdir_p!("priv/static/temp")

    # Create pseudo-HTML with classes
    html_content =
      classes
      |> Enum.map(&"<div class=\"#{&1}\"></div>")
      |> Enum.join("\n")

    File.write!("priv/static/temp/tailwind_classes.html", html_content)
  end
end

Where Next?

Popular in Questions Top

New
9mm
I am constructing a JSON object (map) and I need to conditionally set a field. I’m trying to write proper elixir-way code… and I’m at a l...
New
Harrisonl
We have an ECS cluster with 4 services, where each task joins a single cluster, via discovery ECS discovery service. Currently when I de...
New
qwerescape
Is there a way to get the call stack or stack trace at any point in the code? Not from exceptions, but an expression that returns how the...
New
JulienCorb
I am trying to implement my new.html.eex file to create new posts on my website. new.html.eex: &lt;h1&gt;Create Post&lt;/h1&gt; &lt;%= ...
New
LegitStack
I’m trying to make a websocket server in Phoenix or raw Elixir. I heard about gun, I think I could use cowboy, but since I’m not that sma...
New
Lily
In templates/appointment/index.html.eex: &lt;%= for appointment &lt;- @appointments do %&gt; &lt;tr&gt; &lt;td&gt;&lt;%= appoi...
New
Emily
I have VueJS GUIs with the project generated using Webpack. I have Elixir modules that will need to be used by the VueJS GUIs. I forese...
New
nobody
Hi! In PHP: $_SERVER[‘SERVER_ADDR’] - in Elixir? Searched the docs for ip address and the web, no good results. Thanks!
New
hariharasudhan94
I would like to know what is the best IDE for elixir development?
New

Other popular topics Top

danschultzer
None of the current solutions worked well for me, so I went ahead and built a user management system from scratch. This project took far...
548 29603 241
New
vertexbuffer
Hello, can anybody help here..? I have a list of players and I what to delete an element, but every for loop the list is reverting to ori...
New
lessless
I believe there are people here who are dealing with CSV files import on the daily basis, and since Excel is a really popular tool there ...
New
jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod – where is this set? Thanks.
New
belgoros
I’m not a pro in using Regex and can’t figure out why the following behaviour happens, especially if we take into account the difference ...
New
vonH
When I run the Plug and I recompile I wind up having to use Ctrl C to quit iex and start again. Witht the help of rlwrap I can use the cu...
New
klo
Got a question about when to concat vs. prepending items to list then reversing to achieve appending. So i know lists boil down to [1 | ...
New
AstonJ
We’ve put together this wiki for Phoenix LiveView - please feel free to add any info you feel is worth including. What is Phoenix LiveV...
New
jononomo
For some reason my phoenix channels are working for me in my local dev environment, but as soon as I deploy via Docker, I get a 403 error...
New
vonH
In asking this question I am more interested about the expressiveness of the language itself and less concerned about the availability of...
New

Latest on Elixir Forum

Elixir Forum

We're in Beta

About us Mission Statement