Built-in Plugins

Volt includes built-in support for Vue, Svelte, and React. These are activated automatically when you import .vue or .svelte files, or configure import_source: "react".

Using Plugins

Add plugins to your Volt config:

config :volt, plugins: [MyApp.MarkdownPlugin]

Plugins can also accept options as {module, opts} tuples:

config :volt, plugins: [{MyApp.SassPlugin, output_style: :compressed}]

Writing Plugins

Implement the Volt.Plugin behaviour. All callbacks except name/0 are optional — implement only the hooks you need.

Example: Markdown imports

Load .md files as HTML string modules:

defmodule MyApp.MarkdownPlugin do
  @behaviour Volt.Plugin

  @impl true
  def name, do: "markdown"

  @impl true
  def resolve(spec, _importer) do
    if String.ends_with?(spec, ".md"), do: {:ok, spec}
  end

  @impl true
  def load(path) do
    if String.ends_with?(path, ".md") do
      html = path |> File.read!() |> Earmark.as_html!()
      {:ok, "export default #{Jason.encode!(html)};\n"}
    end
  end

  def resolve(_, _), do: nil
  def load(_), do: nil
end
import readme from './README.md'
document.getElementById('content').innerHTML = readme

Example: Banner injection

Use render_chunk/2 to prepend a license banner to production output:

defmodule MyApp.BannerPlugin do
  @behaviour Volt.Plugin

  @banner "/* © 2026 MyApp — MIT License */\n"

  @impl true
  def name, do: "banner"

  @impl true
  def render_chunk(code, %{type: :entry}), do: {:ok, @banner <> code}
  def render_chunk(_code, _chunk_info), do: nil
end

Example: Compile-time constants

Use define/1 to inject build-time values:

defmodule MyApp.BuildInfo do
  @behaviour Volt.Plugin

  @impl true
  def name, do: "build-info"

  @impl true
  def define(_mode) do
    {hash, 0} = System.cmd("git", ["rev-parse", "--short", "HEAD"])

    %{
      "__BUILD_HASH__" => Jason.encode!(String.trim(hash)),
      "__BUILD_TIME__" => Jason.encode!(DateTime.utc_now() |> to_string())
    }
  end
end
console.log(`Build ${__BUILD_HASH__} at ${__BUILD_TIME__}`)

Example: Custom file compilation with OXC

Use compile/3 to handle a custom file format, transforming output with OXC:

defmodule MyApp.CSVPlugin do
  @behaviour Volt.Plugin

  @impl true
  def name, do: "csv"

  @impl true
  def extensions(:compile), do: [".csv"]
  def extensions(:resolve), do: [".csv"]
  def extensions(_), do: []

  @impl true
  def resolve(spec, _importer) do
    if String.ends_with?(spec, ".csv"), do: {:ok, spec}
  end

  def resolve(_, _), do: nil

  @impl true
  def compile(path, source, opts) do
    if Path.extname(path) == ".csv" do
      rows =
        source
        |> String.split("\n", trim: true)
        |> Enum.map(&String.split(&1, ","))

      js = "export default #{Jason.encode!(rows)};\n"

      {:ok, %{code: js, sourcemap: nil, css: nil, hashes: nil}}
    end
  end
end
import data from './prices.csv'
// data = [["name", "price"], ["Widget", "9.99"], ...]

Example: AST transform with OXC

Use transform/2 to modify compiled JavaScript. OXC provides parse/2, postwalk/3, and patch_string/2 for AST-based transforms:

defmodule MyApp.StripConsolePlugin do
  @behaviour Volt.Plugin

  @impl true
  def name, do: "strip-console"

  @impl true
  def transform(code, _path) do
    case OXC.parse(code, "module.js") do
      {:ok, ast} ->
        patches =
          collect_console_calls(ast)
          |> Enum.map(fn %{start: s, end: e} ->
            %{start: s, end: e, change: "void 0"}
          end)

        if patches == [] do
          nil
        else
          {:ok, OXC.patch_string(code, patches)}
        end

      {:error, _} ->
        nil
    end
  end

  defp collect_console_calls(ast) do
    {_ast, calls} =
      OXC.postwalk(ast, [], fn
        %{
          type: :call_expression,
          callee: %{
            type: :member_expression,
            object: %{type: :identifier, name: "console"}
          }
        } = node, acc ->
          {node, [node | acc]}

        node, acc ->
          {node, acc}
      end)

    calls
  end
end

Hooks

All hooks are optional. Return nil to pass to the next plugin.

HookPurpose
name/0Plugin identifier (required)
extensions/1File extensions for :compile, :resolve, :watch, or :scan
resolve/2Resolve import specifiers to file paths
load/1Load file content for a resolved path
compile/3Compile source into browser-ready JS + optional CSS
extract_imports/3Extract import specifiers from source
transform/2Transform compiled JS before serving or bundling
define/1Compile-time variable replacements
prebundle_alias/1Canonical prebundle specifier for an import
prebundle_entry/1Generated prebundle entry module
render_chunk/2Transform final output chunks

Hook execution order

During compilation, hooks run in this order:

  1. resolve — map import specifier to a file path
  2. load — read file content (override File.read)
  3. compile — transform source into JS + CSS
  4. extract_imports — find imports for dependency walking
  5. transform — post-process compiled JS
  6. render_chunk — modify final bundled output

define/1 runs once at build start. extensions/1 is checked throughout to determine which files a plugin handles.

Plugin options

When configured as a {module, opts} tuple, the opts are passed as an extra argument to callbacks that support it. Define a callback with one additional arity to receive them:

defmodule MyApp.SassPlugin do
  @behaviour Volt.Plugin

  @impl true
  def name, do: "sass"

  # 3-arg version (standard)
  def compile(path, source, opts), do: compile(path, source, opts, [])

  # 4-arg version receives plugin opts
  def compile(path, source, opts, plugin_opts) do
    style = Keyword.get(plugin_opts, :output_style, :expanded)
    # ...
  end
end

JavaScript Runtimes

Plugins can run JavaScript build tools through Volt.JS.Runtime, which installs npm packages into Volt's cache and executes them in QuickBEAM without requiring Node.js in the host application:

defmodule MyApp.SassPlugin do
  @behaviour Volt.Plugin

  @runtime_name __MODULE__.Runtime
  @runtime_packages %{"sass" => "^1.80.0"}

  @impl true
  def name, do: "sass"

  @impl true
  def extensions(:compile), do: [".scss"]
  def extensions(:resolve), do: [".scss"]
  def extensions(_), do: []

  @impl true
  def compile(path, source, _opts) do
    if Path.extname(path) == ".scss" do
      runtime =
        Volt.JS.Runtime.ensure!(
          name: @runtime_name,
          packages: @runtime_packages,
          entry: {:volt_asset, "sass-runtime.ts"},
          bundle: true
        )

      case Volt.JS.Runtime.call(runtime, "compileSass", [source, path]) do
        {:ok, %{"css" => css}} ->
          js = "var s = document.createElement('style'); s.textContent = #{Jason.encode!(css)}; document.head.appendChild(s);\n"
          {:ok, %{code: js, sourcemap: nil, css: css, hashes: nil}}

        {:error, _} = error ->
          error
      end
    end
  end
end

The runtime automatically installs npm packages on first use and caches the bundled entry script. Subsequent calls reuse the running QuickBEAM instance.