JsFormatter

JsFormatter formats and lints JavaScript and TypeScript embedded in Phoenix LiveView colocated hooks and colocated JavaScript. It includes sensible command profiles for Biome and ESLint, and custom profiles can run other compatible tools without adding an Elixir module.

It operates on the source inside .heex files and ~H sigils. It does not inspect LiveView's generated phoenix-colocated output.

Requirements

Installation

Add the package as a development/test-only dependency. runtime: false keeps it out of application runtime behavior:

def deps do
  [
    {:js_formatter, "~> 0.1.0", only: [:dev, :test], runtime: false}
  ]
end

The package requires LiveView 1.2 but does not declare or run an application process. If the consuming project is still on LiveView 1.1, upgrade LiveView deliberately before adding this formatter.

Install and configure a JavaScript tool in the consuming project. For example:

cd assets
npm install --save-dev @biomejs/biome
# or
npm install --save-dev eslint

JsFormatter does not bundle either executable or impose a JavaScript parser or rule set. TypeScript support with ESLint therefore requires the consuming project's ESLint configuration to select an appropriate TypeScript parser.

Configuration

Configure named command profiles in the consuming application's config/config.exs:

import Config

assets_directory = Path.expand("../assets", __DIR__)

config :js_formatter,
  default_profile: :biome,
  biome: [
    command: "biome",
    args: [
      "check",
      "--write",
      "--colors=off",
      "--max-diagnostics=none",
      "--config-path",
      Path.join(assets_directory, "biome.json")
    ],
    cd: assets_directory
  ],
  eslint: [
    command: "eslint",
    args: [
      "--fix",
      "--format",
      "stylish",
      "--config",
      Path.join(assets_directory, "eslint.config.mjs")
    ],
    cd: assets_directory
  ]

Then keep Phoenix.LiveView.HTMLFormatter as the Mix formatter plugin and register JsFormatter as its formatter for script tags:

# .formatter.exs
[
  import_deps: [:phoenix],
  plugins: [Phoenix.LiveView.HTMLFormatter],
  inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"],
  tag_formatters: %{script: JsFormatter}
]

No formatter module needs to be defined in the consuming application. JsFormatter reads application configuration without starting an application process, so it remains suitable for a development/test-only dependency with runtime: false.

The complete profile definitions above make every command option visible, but :biome and :eslint are built in. When the tools can discover their normal project configuration, this smaller setup is equivalent:

config :js_formatter,
  default_profile: :biome,
  biome: [cd: Path.expand("../assets", __DIR__)],
  eslint: [cd: Path.expand("../assets", __DIR__)]

The built-in Biome arguments are biome check --write --colors=off --max-diagnostics=none. The built-in ESLint arguments are eslint --fix --format stylish. Supplying :args in a named profile replaces that built-in argument list.

Both profiles apply the fixes supported by the configured tool and fail mix format when errors remain. ESLint only changes layout when the project's ESLint rules or plugins provide formatting fixes. Warning diagnostics returned with a successful exit status are remapped to the original Elixir/HEEx source and written to standard error by default.

Selecting and overriding profiles

Application configuration selects the package-wide default. The optional :js_formatter section of .formatter.exs can select a different profile or override values for that formatter:

[
  plugins: [Phoenix.LiveView.HTMLFormatter],
  tag_formatters: %{script: JsFormatter},
  js_formatter: [profile: :eslint]
]

Configuration precedence is:

  1. the built-in :biome or :eslint profile;
  2. the selected named profile from config :js_formatter;
  3. overrides in .formatter.exs under :js_formatter.

:profile in .formatter.exs takes precedence over :default_profile.

Profile options

default_profile is an application-level option rather than a profile option. It defaults to :biome.

Commands are invoked directly with System.cmd/3; they are not interpreted by a shell. Keep the executable and arguments separate instead of configuring a single value such as "biome check --write". This prevents quoting problems and keeps source paths containing spaces safe. If a tool requires shell features such as a pipeline, point :command at a project-owned wrapper executable.

Custom profiles

A custom profile needs no Elixir integration module. Its command must accept the isolated source path as its final argument, update that file in place, exit with status 0 when successful, and print diagnostics plus return a nonzero status when errors remain:

config :js_formatter,
  default_profile: :project_js,
  project_js: [
    command: "scripts/check-colocated-js",
    args: ["--fix"],
    cd: Path.expand("..", __DIR__),
    env: %{"NODE_ENV" => "development"}
  ]

The profile can also be defined entirely in .formatter.exs by providing :profile, :command, and any desired options there.

Biome files.includes troubleshooting

Biome applies files.includes to the isolated file that JsFormatter creates, not to the original .ex or .heex path. A restrictive include list can therefore produce No files were processed even though the colocated script is valid.

Place the isolated file inside a directory already accepted by the Biome configuration. In Phoenix projects, setting :cd to assets is often enough because it is also the default temporary-file directory:

assets_directory = Path.expand("../assets", __DIR__)

config :js_formatter,
  biome: [
    cd: assets_directory
  ]

If the command must run from a different directory, set :temporary_directory independently to a directory matched by files.includes:

config :js_formatter,
  biome: [
    cd: Path.expand("..", __DIR__),
    temporary_directory: Path.expand("../assets/js", __DIR__)
  ]

Alternatively, expand the Biome include patterns to accept the configured temporary directory. Temporary files use a js_formatter_ prefix and retain the colocated source's js, jsx, ts, or tsx extension. They are removed after success, command failure, or exception.

Supported tags

Both forms are supported, including js, jsx, ts, and tsx extensions:

<script :type={Phoenix.LiveView.ColocatedHook} name=".Example" extension="ts">
  export default {
    mounted() {
      console.log(this.el)
    }
  }
</script>

<script :type={Phoenix.LiveView.ColocatedJS} name="ExampleElement" extension="ts">
  export default class ExampleElement extends HTMLElement {}
</script>

An extension on manifest, such as manifest="hooks.ts", is also recognized for compatibility with early LiveView 1.2 examples. Ordinary <script> tags are returned unchanged.

Why commands receive a temporary file

LiveView's tag formatter gives a formatter the script text, attributes, and original formatter file metadata. It does not create a JavaScript file.

Biome's --stdin-file-path is useful for formatting, but Biome's stdin path is not equivalent to normal file traversal for combined check diagnostics and failure status. This was verified against Biome 2.5.12: without --write, a valid input that needs changes fails with “The contents aren't fixed”; with --write, a configured noConsole error returns processed source with status 0 and no traversal diagnostics. The JSON reporter is also not applied to that stdin path. A supported library API is not exposed by the native executable, while the daemon and LSP protocols are designed for long-lived editor integrations rather than deterministic mix format calls.

For that reason the built-in Biome profile runs one biome check --write against an isolated real file. That single command applies formatting, safe lint fixes, and safe assist actions, then returns reliable diagnostics and a nonzero status for errors that remain. The built-in ESLint profile uses the equivalent eslint --fix file path, which keeps both tools on the same simple and testable lifecycle.

Temporary names include the OS process and a VM-monotonic unique value, so concurrent formatter processes do not share files. Creation is exclusive, the command is invoked with an argument list, and the file is removed in an after block on success, tool failure, or exception. Diagnostics replace the temporary path with the original Elixir/HEEx path and map snippet line numbers from the located script body when possible.

Architecture

Phoenix.LiveView.HTMLFormatter continues to parse and rewrite HEEx. It invokes JsFormatter only through the configured tag_formatters entry. JsFormatter filters colocated scripts, determines the source extension, creates the isolated file, and invokes the selected command profile. The command either updates the isolated file for LiveView to put back into the original template or fails with JsFormatter.Error and remapped diagnostics.

Phoenix integration demo

The demo directory is a generated Phoenix application that uses this repository as a path dependency. It provides an end-to-end consumer fixture with real TypeScript and TSX examples and runs mix format against project-local, pinned Biome and ESLint installations. It is intentionally kept outside the published package files.