MishkaCmsCore.Runtime.Compilers.TailwindCompiler (Mishka CMS Core v0.0.1)

Copy Markdown View Source

Production-ready Tailwind CSS v4 compiler for runtime CSS generation with multi-site support.

The TailwindCompiler provides comprehensive CSS compilation capabilities for the MishkaCms ecosystem, integrating with AssetsState for efficient site-specific CSS management and caching. It supports dynamic template scanning, custom variants, Phoenix LiveView integration, and production-optimized asset compression.

Features

  • Multi-Site CSS Compilation: Generate and manage CSS for multiple sites independently
  • Runtime Template Scanning: Dynamically scans templates and JavaScript assets for Tailwind classes
  • Phoenix LiveView Integration: Built-in support for LiveView loading states and lifecycle
  • Asset Compression: Automatic Brotli and Gzip compression for optimized delivery
  • AssetsState Integration: Seamless integration with MishkaCms asset management system
  • Production Optimization: Minified output with configurable source paths
  • Dark Mode Support: Custom dark mode variant using data attributes
  • Custom Variants: Pre-configured Phoenix-specific loading state variants

Static base (compile time) vs runtime DB content

Tailwind classes reach this CMS from two timelines:

  • Compile time — the host app's lib/**, the extensions (__extensions/**, later deps/mishka_*), and assets/css/app.css. These don't change after a release is built, so Tailwind's own scanner reads them ONCE via mix mishka.compile_static_css, which writes priv/static/temp/static_base.css. That file ships in the release and is prepended verbatim to every site's CSS. No regex — the scanner reads every class={[...]} list, && conditional, macro and sigil shape natively.

  • Runtime — DB-stored Component / Page / Layout / Stylesheet rows. These change when an admin saves, so they're scanned by Tailwind at boot and on each per-resource Oban job, then concatenated on top of the static base.

In development the static_base.css file is absent, so the compiler scans the live source tree directly (see host_app_web_sources/0). Tailwind has the real files, so nothing is missed. The precompiled file exists only for releases, where the source tree isn't on disk.

Theme tokens (@theme) — the one trade-off to know

A custom utility such as bg-brand-500 only generates CSS if its token (--color-brand-500) is defined in an @theme {} block present in the SAME Tailwind compile. Tokens can live in two places:

  • assets/css/app.css (or a kit theme file) — present at compile time.
  • A Stylesheet DB row of kind: :theme — present only at runtime.

This produces one rule you must respect:

Where the class is usedWhere its token livesResult
DB content (component/page)DB :theme row✅ both meet at runtime
DB contentapp.css✅ token present at runtime too
Static code (lib/, ext)app.css✅ token present at compile time
Static codeDB :theme only❌ dropped — see below

The failure case: a custom color used in static code whose token lives only in the DB. At compile time the token doesn't exist, so the static base never gets the rule; at runtime the class isn't in DB content, so it's never generated. It silently produces no CSS.

Rule: custom color in a static file ⇒ define its token in a static file

To use bg-brand-500 in a LiveView/component under lib/ or an extension, declare the token in assets/css/app.css:

/* assets/css/app.css */
@theme {
  --color-brand-500: oklch(0.62 0.21 12);
}

Now the token is present at compile time, static_base.css resolves bg-brand-500, and it ships in the release. A per-site DB theme can still override the visual value at runtime through the :custom/:theme stylesheet layers — it just can't introduce a brand-new token that static code depends on.

Basic Usage

# Compile CSS for a specific site
case TailwindCompiler.compile("my_site") do
  {:ok, css} ->
    IO.puts("CSS compiled successfully")
    IO.puts("Size: #{byte_size(css)} bytes")

  {:error, reason} ->
    IO.puts("Compilation failed: #{reason}")
end

Integrated Workflow with AssetsState

# Compile and automatically store in AssetsState with compression
case TailwindCompiler.compile_and_store("production_site") do
  {:ok, css} ->
    # CSS is now available in multiple formats:
    # - Raw CSS
    # - Brotli compressed
    # - Gzip compressed
    IO.puts("CSS compiled and cached successfully")

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

# Retrieve compiled CSS from cache
css = TailwindCompiler.get_compiled_css("production_site")

# Get compressed version for optimal delivery
brotli_css = TailwindCompiler.get_compiled_css("production_site", "brotli_css")
gzip_css = TailwindCompiler.get_compiled_css("production_site", "gzip_css")

Asset Management Operations

# Check compilation status
status = TailwindCompiler.get_css_status("my_site")
# Returns: %{
#   site: "my_site",
#   compiled: true,
#   updated_at: ~U[2024-01-01 12:00:00Z],
#   css_size: 125_000
# }

# Refresh CSS (recompile and update cache)
{:ok, fresh_css} = TailwindCompiler.refresh_css("my_site")

# Check if CSS exists
if TailwindCompiler.css_exists?("my_site") do
  IO.puts("CSS is available")
end

# Clean up CSS for a site
:ok = TailwindCompiler.delete_compiled_css("my_site")

CSS Source Configuration

The compiler automatically configures Tailwind CSS v4 with comprehensive source scanning:

# static_base.css (compile-time) prepended verbatim — host app + extensions
@source "runtime/my_site/js_hook/**/*.template"     # Reachable by no walk, so globbed
@source "runtime/my_site/stylesheet/**/*.template"
@source "runtime/__global__/js_hook/**/*.template"
@source "runtime/__global__/stylesheet/**/*.template"
@source "runtime/my_site/page/home.template"        # One line per REACHABLE template:
@source "runtime/my_site/layout/main.template"      # the site's pages and error pages, the
@source "runtime/__global__/component/alert.template" # layouts they use, the components
@source "runtime/__global__/component/icon.template"  # they name — see `closure_roots/2`
@source "js_assets/js/**/*.js"                   # Runtime JS hooks (NOT vendor/ — see below)
@source "js_assets/js/**/*.ts"                   # TypeScript files

Custom Variants and Phoenix Integration

/* Phoenix LiveView loading states */
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);

/* Data attribute-based dark mode */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));

Usage in Phoenix Controllers/LiveViews

defmodule MyAppWeb.PageController do
  use MyAppWeb, :controller

  def show(conn, %{"site" => site_name}) do
    # Ensure CSS is compiled for the site
    case TailwindCompiler.compile_and_store(site_name) do
      {:ok, _css} ->
        render(conn, :show, site: site_name)

      {:error, reason} ->
        conn
        |> put_flash(:error, "Failed to load site styles")
        |> redirect(to: "/")
    end
  end
end

defmodule MyAppWeb.AdminLive do
  use MyAppWeb, :live_view

  def handle_event("refresh_css", %{"site" => site}, socket) do
    case TailwindCompiler.refresh_css(site) do
      {:ok, _css} ->
        {:noreply, put_flash(socket, :info, "CSS refreshed successfully")}

      {:error, reason} ->
        {:noreply, put_flash(socket, :error, "Failed to refresh CSS: #{reason}")}
    end
  end
end

Performance Optimization

# Batch operations for multiple sites
sites = ["site1", "site2", "site3"]

compiled_sites =
  Task.async_stream(sites, &TailwindCompiler.compile_and_store/1,
    max_concurrency: 3,
    timeout: 30_000
  )
  |> Enum.map(fn {:ok, result} -> result end)

Error Handling and Debugging

case TailwindCompiler.compile("problematic_site") do
  {:ok, css} ->
    Logger.info("Successfully compiled #{byte_size(css)} bytes of CSS")

  {:error, reason} ->
    Logger.error("Tailwind compilation failed: #{reason}")

    # Check system requirements
    if !System.find_executable("node") do
      Logger.error("Node.js not found - required for Tailwind compilation")
    end

    # Verify template paths exist
    temp_dir = System.tmp_dir()
    Logger.info("Using temporary directory: #{temp_dir}")
end

Production Deployment Considerations

  • Asset Precompilation: Consider pre-compiling CSS during deployment for faster startup
  • Memory Management: CSS assets are cached in memory; monitor memory usage with many sites
  • Concurrent Compilation: Use Task.async_stream for bulk compilation operations
  • File System Access: Ensure proper permissions for temporary directory operations
  • Node.js Dependency: Requires Node.js runtime for Tailwind CLI execution

Asset Compression Details

All compiled CSS is automatically compressed using multiple algorithms:

  • Brotli: Best compression ratio, supported by modern browsers
  • Gzip: Universal compatibility, good compression ratio
  • Raw CSS: Uncompressed for development and fallback scenarios

The compiler calculates MD5 hashes for cache invalidation and stores metadata including compilation timestamps and asset sizes.

Summary

Functions

The CSS an author wrote, without the at-rules that make the Tailwind binary read a file or run code on the server, and the names of the ones left out: {css, ["@import", …]}.

The templates a visitor can reach on site_name — the roots of its CSS closure.

Compiles Tailwind CSS from dynamic templates and assets for a specific site.

Compiles Tailwind CSS and stores all compressed variants in AssetsState for optimized delivery.

Compiles shared base CSS and stores it in AssetsState for reuse.

Compile a ONE-OFF CSS bundle for a draft/preview: the shared base + the site's classes + the draft's OWN template (so brand-new classes in an unpublished draft are styled). Writes the draft template into an isolated temp namespace, scans it alongside the site, and removes it after — it NEVER touches the live site bundle. Returns {:ok, css} | {:error, reason}.

Compiles ONLY the CSS every site shares: the host shell, app.css, js_assets, and the global templates no reachability walk can reach (JS hooks and stylesheet rows).

Compiles the static base CSS at compile/release time and writes it to static_base_path/0.

The classes the stored content of the site site_name writes — a class="bg-[#282828]" in its HTML, a marker's class and *_class attributes, and the attributes its component takes a class string in (MishkaCmsCore.Runtime.ContentComponents.site_classes/2). No template holds them, so no scan finds them: the site's stylesheet is built with each as an @source inline(…) candidate.

The component names the stored content of the site site_name uses — what a template drawing that content reaches (MishkaCmsCore.Runtime.Compilers.CssClosure).

The icon classes the stored content of the site site_name names — a marker's icon="hero-inbox", a <span class="hero-inbox"> in its HTML (MishkaCmsCore.Runtime.ContentComponents.icons/1). No template holds them, so no scan finds them: the site's stylesheet carries a rule for each a registered set ships, beside the icons its templates name.

Checks if compiled CSS exists for a specific site in the cache.

Deletes all compiled CSS variants for a specific site from the cache.

Invalidate the cached shared-base CSS so the next compile rebuilds it from scratch.

Ensures Tailwind binary is installed. Call once at boot time.

Retrieves compiled CSS from AssetsState cache for a specific site and format.

Gets comprehensive CSS compilation status and metadata for a specific site.

Gets the cached shared base CSS, or compiles it if not available.

Refreshes CSS for a site by recompiling and updating the cache.

Reads the version a tailwindcss --help output names, whether or not the binary painted it.

Enqueues the stylesheet build of the site site_id names — its "css" job, on the per-site path that coalesces a burst of writes into one build. For a write that changed what the stylesheet is built from without a component, page or layout being written: content that names a component, an icon or a class it did not name before.

Where the compiled runtime templates and CSS live.

Whether a candidate stylesheet fragment compiles for a site, in Tailwind's own words when it does not: :ok | {:error, message}.

Functions

author_css(css)

@spec author_css(String.t()) :: {String.t(), [String.t()]}

The CSS an author wrote, without the at-rules that make the Tailwind binary read a file or run code on the server, and the names of the ones left out: {css, ["@import", …]}.

A stylesheet row is pasted into the file Tailwind reads. There @import "../x.css" copies any local CSS file into the page's public CSS, @source "../x" scans any local file for class names, and @plugin, @config and @reference load a local file — a plugin is JavaScript Tailwind runs. Left out:

  • @import, unless it names an http:// or https:// address (a font stylesheet);
  • @source, unless it is @source inline(…), which only lists class names;
  • @plugin, @config and @reference;
  • any at-rule whose name is written with a backslash escape.

Everything else — @theme, @apply, @media, @layer, @custom-variant, @utility — is kept as it was written.

closure_roots(site_name, opts \\ [])

@spec closure_roots(String.t(), keyword()) :: [String.t()]

The templates a visitor can reach on site_name — the roots of its CSS closure.

A site's pages and error pages are roots because a visitor can ask for either by URL. Its LAYOUTS are not: a layout is chosen by a database relation rather than named in markup, so a site with thirty layout templates and five pages was scanning twenty-five it can never render, and every global layout sat in every site's stylesheet. The layouts this site's pages actually name are read from pages.layout_id and added here — which is also what makes editing a global layout a rebuild of the sites that use it rather than a fact about all of them.

Read here rather than inside MishkaCmsCore.Runtime.Compilers.CssClosure, which walks a directory and deliberately knows nothing about the database. If the read fails the layouts fall back to the whole directory, on the same principle as :unbounded — correct-and-large, never small-and-broken.

compile(site_name)

Compiles Tailwind CSS from dynamic templates and assets for a specific site.

This optimized version:

  1. Uses cached shared base CSS (global templates, js_assets) - avoids rescanning N times
  2. Only compiles site-specific templates
  3. Combines shared base + site-specific CSS

Parameters

  • site_name - String identifier for the site (used for template scanning and caching)

Return Values

  • {:ok, css} - Successfully compiled CSS as a minified string
  • {:error, reason} - Compilation failed

Performance Optimization

With 20 sites, instead of scanning global templates 20 times:

  • Shared base compiled ONCE (~225ms)
  • Each site only scans its own templates (~50-100ms)
  • Total: ~225ms + 20×75ms = ~1.7s instead of 20×225ms = ~4.5s

There used to be a second way to answer this — compile_full/1, a use_shared_base: false mode that assembled the whole stylesheet in one Tailwind run. Nothing called it outside its own tests, and it restated every decision made here, so the two drifted apart with each change to what gets scanned. A stale shared base is dropped with delete_shared_base_css/0 instead, which is what every writer already does.

compile_and_store(site_name)

Compiles Tailwind CSS and stores all compressed variants in AssetsState for optimized delivery.

This function combines compilation with intelligent caching by automatically generating and storing multiple compressed formats (raw, Brotli, Gzip) along with metadata for efficient asset serving. It provides the complete workflow for production CSS management.

Parameters

  • site_name - String identifier for the site

Return Values

  • {:ok, css} - Successfully compiled and stored CSS (returns the raw CSS string)
  • {:error, reason} - Compilation or storage failed with error message

Storage Process

  1. CSS Compilation: Generates minified CSS using compile/1
  2. Multi-format Compression: Creates Brotli and Gzip compressed variants
  3. Metadata Generation: Calculates MD5 hash and timestamp for cache invalidation
  4. Batch Storage: Atomically stores all formats in AssetsState
  5. Logging: Records successful operations for monitoring

Stored Asset Formats

  • "css" - Raw minified CSS for development and fallback
  • "brotli_css" - Brotli compressed CSS (best compression, ~20-30% smaller)
  • "gzip_css" - Gzip compressed CSS (universal compatibility, ~15-25% smaller)

Examples

# Basic compilation and storage
case TailwindCompiler.compile_and_store("ecommerce_site") do
  {:ok, css} ->
    IO.puts("CSS compiled and cached")
    IO.puts("Available formats: css, brotli_css, gzip_css")

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

# Use in a Phoenix plug for automatic CSS generation
defmodule EnsureCssPlug do
  def init(opts), do: opts

  def call(conn, _opts) do
    site = get_site_from_conn(conn)

    if !TailwindCompiler.css_exists?(site) do
      case TailwindCompiler.compile_and_store(site) do
        {:ok, _css} -> conn
        {:error, reason} ->
          conn
          |> put_status(500)
          |> Phoenix.Controller.text("CSS compilation failed: #{reason}")
          |> halt()
      end
    else
      conn
    end
  end
end

# Bulk compilation for multiple sites
sites = ["blog", "shop", "portfolio"]

results =
  Task.async_stream(
    sites,
    &TailwindCompiler.compile_and_store/1,
    max_concurrency: 2,
    timeout: 60_000
  )
  |> Enum.map(fn {:ok, result} -> result end)

Asset Metadata

Each stored asset includes comprehensive metadata:

  • hash - MD5 hash for cache busting and integrity verification
  • updated_at - UTC timestamp for cache management
  • value - The actual CSS content (raw or compressed)
  • type - Format identifier ("css", "brotli_css", "gzip_css")

Integration with HTTP Serving

defmodule CssController do
  def serve_css(conn, %{"site" => site}) do
    content_encoding = get_best_encoding(conn)
    css_type = encoding_to_type(content_encoding)

    case AssetsState.get(site, css_type) do
      {:ok, asset} ->
        conn
        |> put_resp_content_type("text/css")
        |> put_resp_header("content-encoding", content_encoding)
        |> put_resp_header("etag", asset.hash)
        |> put_resp_header("cache-control", "public, max-age=31536000")
        |> send_resp(200, asset.value)

      {:error, :not_found} ->
        # Trigger compilation on demand
        case TailwindCompiler.compile_and_store(site) do
          {:ok, _css} -> serve_css(conn, %{"site" => site})
          {:error, _} -> send_resp(conn, 500, "CSS unavailable")
        end
    end
  end
end

Performance Benefits

  • Reduced Bandwidth: Compression reduces transfer sizes by 15-30%
  • Faster Loading: Pre-compressed assets eliminate server-side compression overhead
  • Cache Efficiency: Hash-based versioning enables aggressive browser caching
  • CDN Optimization: Multiple formats support optimal CDN distribution

compile_and_store_shared_base()

Compiles shared base CSS and stores it in AssetsState for reuse.

compile_preview(site_name, preview_ns, template_name, template_content)

Compile a ONE-OFF CSS bundle for a draft/preview: the shared base + the site's classes + the draft's OWN template (so brand-new classes in an unpublished draft are styled). Writes the draft template into an isolated temp namespace, scans it alongside the site, and removes it after — it NEVER touches the live site bundle. Returns {:ok, css} | {:error, reason}.

compile_shared_base()

Compiles ONLY the CSS every site shares: the host shell, app.css, js_assets, and the global templates no reachability walk can reach (JS hooks and stylesheet rows).

This should be called ONCE during boot. The result is cached in AssetsState with id "shared_base" and reused by all site compilations.

Global LAYOUTS, ERROR PAGES and COMPONENTS are deliberately absent — a site's stylesheet carries what that site renders, so those are reached per site in compile/1.

Performance Benefit

  • static_base.css prepended once instead of re-scanned N times
  • the shared global templates scanned once instead of N times
  • js_assets/js/** scanned once instead of N times

Why js_assets/js/** and not js_assets/**

js_assets/vendor/ holds tailwind-browser.js — the page builder's in-browser Tailwind engine, 281 KB whose whole job is to know every utility name there is. Scanning it as a SOURCE meant Tailwind read the editor's own name tables as if a template had asked for them, and generated real rules into the base every site's stylesheet is built on: a measured 42 KB, of which the great majority matched no template anywhere. The site's actual JS hooks live under js_assets/js/, so the narrower glob keeps them and drops the engine.

Return Values

  • {:ok, css} - Successfully compiled shared CSS
  • {:error, reason} - Compilation failed

compile_static_base()

Compiles the static base CSS at compile/release time and writes it to static_base_path/0.

Scans the RUNTIME SHELL — the layouts module, its templates and RuntimeLive — plus the JS assets and app.css (for @theme tokens), with Tailwind's own scanner. The result ships in the release and is prepended to every site's CSS at runtime, because a release has no .ex files to scan.

Run from mix mishka.compile_static_css (wired into the release build). See the moduledoc "Static base" and "Theme tokens" sections.

content_classes(site_name)

@spec content_classes(String.t()) :: [String.t()]

The classes the stored content of the site site_name writes — a class="bg-[#282828]" in its HTML, a marker's class and *_class attributes, and the attributes its component takes a class string in (MishkaCmsCore.Runtime.ContentComponents.site_classes/2). No template holds them, so no scan finds them: the site's stylesheet is built with each as an @source inline(…) candidate.

Answered in classes by the same runtime:compile:content_components event as content_components/1; a token that could not be a class is left out, and at most 20000 are kept. [] for a site SiteState does not know, and while the event cannot run.

content_components(site_name)

@spec content_components(String.t()) :: [String.t()]

The component names the stored content of the site site_name uses — what a template drawing that content reaches (MishkaCmsCore.Runtime.Compilers.CssClosure).

Asked of whoever stores content through runtime:compile:content_components, with %{site: name, site_id: id, components: [], icons: [], classes: []}: a plugin adds the names its content uses to components, the icon classes it names to icons (content_icons/1), and the classes it writes to classes (content_classes/1). Read while a stylesheet is built, never in a request. [] for a site SiteState does not know, and while the event cannot run.

def call(%{site_id: site_id, components: names, icons: icons, classes: classes} = data) do
  {:reply,
   %{
     data
     | components: names ++ names_my_posts_use(site_id),
       icons: icons ++ icons_my_posts_use(site_id),
       classes: classes ++ classes_my_posts_use(site_id)
   }}
end

content_icons(site_name)

@spec content_icons(String.t()) :: [String.t()]

The icon classes the stored content of the site site_name names — a marker's icon="hero-inbox", a <span class="hero-inbox"> in its HTML (MishkaCmsCore.Runtime.ContentComponents.icons/1). No template holds them, so no scan finds them: the site's stylesheet carries a rule for each a registered set ships, beside the icons its templates name.

Answered in icons by the same runtime:compile:content_components event as content_components/1. [] for a site SiteState does not know, and while the event cannot run.

css_exists?(site_name)

Checks if compiled CSS exists for a specific site in the cache.

This function provides a quick boolean check for CSS availability without retrieving the actual content, making it ideal for conditional compilation logic and cache validation.

Parameters

  • site_name - String identifier for the site

Return Value

  • true - CSS exists and is available in the cache
  • false - CSS has not been compiled or has been removed

Examples

# Check before attempting to serve CSS
if TailwindCompiler.css_exists?("blog_site") do
  css = TailwindCompiler.get_compiled_css("blog_site")
  send_css_response(conn, css)
else
  # Trigger compilation
  {:ok, _css} = TailwindCompiler.compile_and_store("blog_site")
  redirect_to_css(conn)
end

# Batch check multiple sites
sites = ["site1", "site2", "site3"]
compiled_sites = Enum.filter(sites, &TailwindCompiler.css_exists?/1)
missing_sites = sites -- compiled_sites

# Use in a deployment health check
defmodule HealthCheck do
  def css_status do
    required_sites = Application.get_env(:app, :required_sites)

    Enum.map(required_sites, fn site ->
      %{
        site: site,
        css_ready: TailwindCompiler.css_exists?(site)
      }
    end)
  end
end

delete_compiled_css(site_name)

Deletes all compiled CSS variants for a specific site from the cache.

This function removes all cached CSS formats (raw, Brotli, Gzip) for a site, effectively clearing the cache and forcing recompilation on the next request. Useful for cache invalidation when templates or styles are updated.

Parameters

  • site_name - String identifier for the site

Return Value

  • :ok - CSS successfully deleted or didn't exist

Examples

# Delete CSS after template updates
:ok = TailwindCompiler.delete_compiled_css("updated_site")

# Next request will trigger recompilation
{:ok, fresh_css} = TailwindCompiler.compile_and_store("updated_site")

# Use in a cache management task
defmodule CacheManager do
  def invalidate_site_css(site_name) do
    Logger.info("Invalidating CSS cache for site: #{site_name}")
    :ok = TailwindCompiler.delete_compiled_css(site_name)

    # Optionally precompile immediately
    Task.start(fn ->
      TailwindCompiler.compile_and_store(site_name)
    end)
  end
end

# Bulk cache clearing
sites_to_clear = ["site1", "site2", "site3"]
Enum.each(sites_to_clear, &TailwindCompiler.delete_compiled_css/1)

Use Cases

  • Template Updates: Clear cache when site templates are modified
  • Development: Reset styles during development iterations
  • Deployment: Clear cache as part of deployment pipeline
  • Memory Management: Periodically clear unused site CSS
  • Error Recovery: Clear potentially corrupted cached CSS

delete_shared_base_css()

Invalidate the cached shared-base CSS so the next compile rebuilds it from scratch.

Why this exists: compile/1 reuses the cached __shared_base__ row in AssetsState rather than rebuilding it per site. The shared base prepends the precompiled static_base.css (release) or live-scans the host app's lib/** + __extensions/** (dev, via host_app_web_sources/0), so any class added to a LiveView will only show up after this cache is cleared.

The dev AssetsWatcher calls this on every host-app file event so a <div class="grid-cols-3"> you just typed makes it into the next CSS build. Production deployments don't need it — the shared base is rebuilt at boot.

ensure_installed()

Ensures Tailwind binary is installed. Call once at boot time.

This avoids repeated install checks during install_and_run/2 calls. After calling this, use run/2 instead of install_and_run/2.

get_compiled_css(site_name, type \\ "css")

Retrieves compiled CSS from AssetsState cache for a specific site and format.

This function provides fast access to pre-compiled CSS assets without triggering recompilation. It supports multiple compression formats for optimal delivery based on client capabilities and performance requirements.

Parameters

  • site_name - String identifier for the site
  • type - Asset format type (defaults to "css")
    • "css" - Raw minified CSS
    • "brotli_css" - Brotli compressed CSS
    • "gzip_css" - Gzip compressed CSS

Return Value

  • binary() - The CSS content as a binary string
  • Returns a CSS comment with error message if the asset doesn't exist

Examples

# Get standard CSS
css = TailwindCompiler.get_compiled_css("my_site")

# Get compressed format for optimal delivery
brotli_css = TailwindCompiler.get_compiled_css("my_site", "brotli_css")
gzip_css = TailwindCompiler.get_compiled_css("my_site", "gzip_css")

# Use in Phoenix controller with content negotiation
defmodule AssetController do
  def css(conn, %{"site" => site}) do
    format = case get_req_header(conn, "accept-encoding") do
      [encoding] when is_binary(encoding) ->
        cond do
          String.contains?(encoding, "br") -> "brotli_css"
          String.contains?(encoding, "gzip") -> "gzip_css"
          true -> "css"
        end
      _ -> "css"
    end

    css_content = TailwindCompiler.get_compiled_css(site, format)

    conn
    |> put_resp_content_type("text/css")
    |> maybe_put_encoding_header(format)
    |> send_resp(200, css_content)
  end

  defp maybe_put_encoding_header(conn, "brotli_css"),
    do: put_resp_header(conn, "content-encoding", "br")
  defp maybe_put_encoding_header(conn, "gzip_css"),
    do: put_resp_header(conn, "content-encoding", "gzip")
  defp maybe_put_encoding_header(conn, _), do: conn
end

Error Handling

# CSS not found scenario
css = TailwindCompiler.get_compiled_css("non_existent_site")
# Returns: "/* CSS not found for site: non_existent_site */"

# Handle missing CSS gracefully
css = case TailwindCompiler.get_compiled_css("my_site") do
  "/* CSS not found" <> _ ->
    # Trigger compilation if CSS is missing
    case TailwindCompiler.compile_and_store("my_site") do
      {:ok, compiled_css} -> compiled_css
      {:error, _} -> "/* Compilation failed */"
    end
  valid_css -> valid_css
end

Performance Notes

  • This function performs a direct cache lookup with minimal overhead
  • No disk I/O or compilation occurs - purely memory-based retrieval
  • Consider using compressed formats for production traffic
  • Falls back gracefully with informative error messages for missing assets

get_css_status(site_name)

Gets comprehensive CSS compilation status and metadata for a specific site.

This function returns detailed information about the CSS compilation state, including availability, timestamps, and size metrics. It's useful for monitoring, debugging, and administrative interfaces.

Parameters

  • site_name - String identifier for the site

Return Value

Returns a map with the following keys:

  • site - The site name (string)
  • compiled - Boolean indicating if CSS exists
  • updated_at - UTC DateTime of last compilation (or nil if not compiled)
  • css_size - Size of CSS in bytes (or 0 if not compiled)

Examples

# Check single site status
status = TailwindCompiler.get_css_status("my_site")
# => %{
#   site: "my_site",
#   compiled: true,
#   updated_at: ~U[2024-01-15 10:30:45Z],
#   css_size: 125_430
# }

# Use in admin dashboard
defmodule AdminDashboard do
  def site_status_list do
    Application.get_env(:app, :managed_sites)
    |> Enum.map(&TailwindCompiler.get_css_status/1)
    |> Enum.sort_by(& &1.updated_at, {:desc, DateTime})
  end
end

# Monitor compilation staleness
status = TailwindCompiler.get_css_status("production_site")

if status.compiled do
  age_hours = DateTime.diff(DateTime.utc_now(), status.updated_at, :hour)

  if age_hours > 24 do
    Logger.warning("CSS for #{status.site} is #{age_hours} hours old")
    TailwindCompiler.refresh_css(status.site)
  end
else
  Logger.error("No CSS compiled for critical site: #{status.site}")
  TailwindCompiler.compile_and_store(status.site)
end

# Health check endpoint
defmodule HealthController do
  def css_health(conn, _params) do
    critical_sites = ["main", "blog", "shop"]

    statuses = Enum.map(critical_sites, &TailwindCompiler.get_css_status/1)
    all_compiled = Enum.all?(statuses, & &1.compiled)

    total_size = Enum.sum(Enum.map(statuses, & &1.css_size))

    health_data = %{
      status: if(all_compiled, do: "healthy", else: "degraded"),
      sites: statuses,
      total_css_size: total_size,
      checked_at: DateTime.utc_now()
    }

    json(conn, health_data)
  end
end

Monitoring Use Cases

  • Admin Dashboards: Display CSS compilation status across all sites
  • Health Checks: Verify critical site CSS is available
  • Performance Monitoring: Track CSS size and compilation frequency
  • Alerting: Trigger alerts for missing or stale CSS
  • Analytics: Gather metrics on CSS usage patterns

get_or_compile_shared_base()

Gets the cached shared base CSS, or compiles it if not available.

host_app_web_sources()

refresh_css(site_name)

Refreshes CSS for a site by recompiling and updating the cache.

This function forces a complete recompilation of CSS for a site, regardless of whether cached CSS already exists. It combines cache invalidation with fresh compilation, ensuring the most up-to-date styles are available.

Parameters

  • site_name - String identifier for the site

Return Values

  • {:ok, css} - Successfully recompiled and cached fresh CSS
  • {:error, reason} - Compilation failed with error message

Examples

# Refresh CSS after template changes
case TailwindCompiler.refresh_css("blog_site") do
  {:ok, css} ->
    Logger.info("Blog CSS refreshed: #{byte_size(css)} bytes")

  {:error, reason} ->
    Logger.error("Failed to refresh blog CSS: #{reason}")
end

# Use in a Phoenix LiveView for admin functionality
defmodule AdminLive do
  def handle_event("refresh_css", %{"site" => site}, socket) do
    case TailwindCompiler.refresh_css(site) do
      {:ok, _css} ->
        {:noreply, put_flash(socket, :info, "CSS refreshed for #{site}")}

      {:error, reason} ->
        {:noreply, put_flash(socket, :error, "Refresh failed: #{reason}")}
    end
  end
end

# Scheduled CSS refresh for active sites
defmodule CssRefreshWorker do
  use Oban.Worker, queue: :css_refresh

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"site" => site}}) do
    case TailwindCompiler.refresh_css(site) do
      {:ok, _css} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end
end

# Bulk refresh for multiple sites
sites = ["blog", "shop", "docs"]

results =
  Task.async_stream(sites, &TailwindCompiler.refresh_css/1)
  |> Enum.map(fn {:ok, result} -> result end)

When to Use

  • Template Updates: After modifying site templates or components
  • Style Changes: When custom CSS or Tailwind configuration changes
  • Deployment: As part of the deployment process to ensure fresh styles
  • Development: During active development for immediate style updates
  • Maintenance: Periodic refresh to clear any potential cache inconsistencies

reported_version(output)

@spec reported_version(String.t()) :: String.t() | nil

Reads the version a tailwindcss --help output names, whether or not the binary painted it.

Pass the raw output. Returns the number as text ("4.3.2"), or nil when the output names no version.

schedule(site_id)

@spec schedule(String.t()) :: :ok | {:ok, Oban.Job.t()} | {:error, term()}

Enqueues the stylesheet build of the site site_id names — its "css" job, on the per-site path that coalesces a burst of writes into one build. For a write that changed what the stylesheet is built from without a component, page or layout being written: content that names a component, an icon or a class it did not name before.

A switched-off or unknown site has nothing to build, and is :ok. So is every call under the :env_test? flag, as for every other runtime job.

temp_dir()

@spec temp_dir() :: String.t()

Where the compiled runtime templates and CSS live.

Public because MishkaCmsCore.Runtime.Compilers.CssClosure walks the same tree.

verify_css(css, site_id)

@spec verify_css(String.t(), String.t() | nil) :: :ok | {:error, String.t()}

Whether a candidate stylesheet fragment compiles for a site, in Tailwind's own words when it does not: :ok | {:error, message}.

For CSS that is ABOUT TO BE STORED as a row. Tailwind v4 answers @apply of a utility it does not know with a non-zero exit, and a stored fragment that does that fails every later compile in its scope — so a writer proves it before writing it rather than after.

Nothing is scanned: the fragment is compiled against the site's theme rows, tailwind_preamble/0 and the utility definitions alone, with no @source line, which is what makes this affordable on a save. The preamble is the half that is easy to forget and the half that matters — a check missing the plugin and the variants is STRICTER than the compiles it stands in for, and refuses utilities this CMS ships. It also means this answers for the fragment and nothing else: a row that is already broken cannot fail somebody's valid class.