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
- Elixir 1.15 or later
- Phoenix LiveView 1.2 or later (the first release with
Phoenix.LiveView.HTMLFormatter.TagFormatter) - A project-local or PATH-installed JavaScript tool
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:
- the built-in
:biomeor:eslintprofile; - the selected named profile from
config :js_formatter; - overrides in
.formatter.exsunder:js_formatter.
:profile in .formatter.exs takes precedence over :default_profile.
Profile options
:command— executable name or path. Executable discovery checksnode_modules/.binbelow:cd, the formatter invocation directory,assets/node_modules/.bin, and thenPATH.:args— a list of command arguments.JsFormatterappends the isolated source path as the final argument.:cd— command working directory and the default temporary-file directory; defaults to the directory in whichmix formatruns.:env— a map of string environment-variable names to string values added to the command environment; defaults to%{}.:temporary_directory— where isolated source files are created; defaults to:cdso normal configuration discovery and path overrides apply.:success_output— controls output from commands that exit successfully.:warnings(the default) prints recognized Biome and ESLint warning output while suppressing routine success summaries;:alwaysprints all nonempty output;:ignorediscards it.:error_on_warnings— fail formatting when a successful command reports warnings; defaults tofalse. This recognizes the standard warning output produced by the built-in Biome and ESLint profiles.:extensions— extensions to process; defaults tojs,jsx,ts, andtsx.:types— accepted:typenames; defaults toColocatedHookandColocatedJS. Fully qualified names and those standard final module segments are accepted. Add a custom alias such as"Hook"explicitly.:format_runtime— opt into processing runtime colocated hooks. It defaults tofalsebecause runtime hook bodies are expressions rather than ordinary JavaScript modules and are not always valid as standalone files.
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.