Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
plugins: [Volt.Formatter],
inputs: [
"{mix,.formatter}.exs",
"{config,lib,test}/**/*.{ex,exs}",
"priv/ts/{client,compilers,test}/**/*.ts"
],
volt: [
trailing_comma: :none,
tab_width: 2,
semi: false,
single_quote: true,
print_width: 100,
arrow_parens: :always
]
]
1 change: 1 addition & 0 deletions .reach.exs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ logic = [
"Volt.HTMLEntry",
"Volt.JS.AST",
"Volt.JS.Check",
"Volt.JS.CommonJS",
"Volt.JS.Lint.Config",
"Volt.JS.Extensions",
"Volt.JS.Format",
Expand Down
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,39 @@
# Changelog

## Unreleased

### Breaking changes

- Formatter options moved from `config :volt, :format` to the `:volt` key of `.formatter.exs`, where `mix format` plugins keep their options. `config :volt, :format` now only holds the build output format (`:iife`, `:esm`, or `:cjs`), so both can be set. A keyword list under `config :volt, :format` raises an `ArgumentError` with the options to move:

# .formatter.exs
[
plugins: [Volt.Formatter],
volt: [semi: false, single_quote: true]
]

The formatting-only `:root`, `:sources`, and `:ignore` overrides for `mix volt.js.format` and `mix volt.js.check` move to the same key. `.oxfmtrc.json` and `.prettierrc.json` are still read when there is no `:volt` key. `mix igniter.install volt` writes the options to `.formatter.exs`.
- External modules stay as imports in `:esm` and `:cjs` output, as in Rollup, Rolldown, esbuild and Bun. With `format: :esm, external: ["phoenix"]`, `import { Socket } from "phoenix"` used to become `const { Socket } = Phoenix;`; it is now left for the browser or host to resolve, for example through an import map. `:cjs` output uses `require("phoenix")`. IIFE output, the default, still reads externals from globals. The global names in `external: %{"phoenix" => "Phoenix"}` apply to IIFE output only. ESM builds that relied on page globals need an import map or `format: :iife`.
- `Volt.JS.Format.load_config/1` takes the `.formatter.exs` options instead of reading the application environment.

### Added

- The dev server converts local CommonJS and UMD files, such as Phoenix's vendored `topbar.js`, to ES modules, so `import topbar from "../vendor/topbar"` works in development as it does in production builds. `.cjs` and `.cts` files, which the dev server did not serve, are converted the same way.
- Relative `watch_ignored` patterns also resolve from the project directory, so `_build/**` matches a watched directory inside `_build`.

- Volt's client types declare `import.meta.env`, so TypeScript projects no longer need their own `ImportMeta` declaration for `MODE`, `DEV`, `PROD` and exposed variables. `mix igniter.install volt` adds `env.d.ts` to configurations that list declaration files explicitly.

### Fixed

- Collect every test generated by `test.each` and `describe.each`. Source lines were matched to tests by position, and tests without a detected line were dropped: a five-case table followed by two tests collected as two tests. Each case now maps to the line of its table, and tests registered in ways the source does not show, such as in a loop, are kept without a line.
- Stop the dev server reloading pages in a loop when a watched file is rewritten with identical content, as Phoenix LiveView does for colocated hooks on every code reload. The watcher now compares file contents before rebuilding.
- Load a single instance of each pre-bundled dependency in development. Pre-bundles imported their siblings and shared chunks without the `?v=` hash that application modules use, so browsers loaded packages such as Vue twice and component libraries built on them failed to render.
- Report why a development session is unavailable. A second watcher with different options, such as a `Mix.Tasks.Volt.Dev` entry in the endpoint's `:watchers` next to `plug Volt.DevServer`, answered every request with a bare 503; the response and the log now name the conflict and how to resolve it.
- Log `Pre-bundled N vendor package(s)` only when packages are bundled. Phoenix initializes plugs on every request in development, so it was logged per request.
- Read external globals once in code-split IIFE output. Each chunk also declared them at the top level of the script, where two chunks importing the same name would clash.
- Stop watchers before the Tailwind processes they release their contexts to. Volt's supervisor stopped the Tailwind registry first, so every watcher with Tailwind enabled crashed with `unknown registry: Volt.Tailwind.Registry` on shutdown.
- Skip `tsconfig.json` path mappings that only point at declaration files. A types-only mapping such as `"topbar": ["./types/topbar.d.ts"]` became a bundler alias and bundled the `.d.ts` file in place of the package.

## 0.19.4 - 2026-10-03

### Compatibility
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,11 @@ JS/TS formatting, linting, and testing run inside the BEAM. `mix format` handles

```elixir
# .formatter.exs
[plugins: [Volt.Formatter], inputs: ["assets/**/*.{js,ts,jsx,tsx}"]]
[
plugins: [Volt.Formatter],
inputs: ["assets/**/*.{js,ts,jsx,tsx}"],
volt: [semi: false, single_quote: true]
]
```

```bash
Expand Down
8 changes: 0 additions & 8 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
import Config

config :volt, :format,
trailing_comma: :none,
tab_width: 2,
semi: false,
single_quote: true,
print_width: 100,
arrow_parens: :always

config :volt, :lint,
plugins: ["typescript", "import", "unicorn"],
rules: %{
Expand Down
3 changes: 2 additions & 1 deletion examples/react/.formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"*.{heex,ex,exs}",
"{config,lib,test}/**/*.{heex,ex,exs}",
"assets/**/*.{js,ts,jsx,tsx}"
]
],
volt: [semi: false, single_quote: true]
]
4 changes: 0 additions & 4 deletions examples/react/config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,6 @@ config :volt,
],
import_source: "react"

config :volt, :format,
semi: false,
single_quote: true

config :volt, :lint,
plugins: ["typescript", "react"],
tsgolint: System.find_executable("tsgolint"),
Expand Down
3 changes: 2 additions & 1 deletion examples/solid/.formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"*.{heex,ex,exs}",
"{config,lib,test}/**/*.{heex,ex,exs}",
"assets/**/*.{js,ts,jsx,tsx}"
]
],
volt: [semi: false, single_quote: true]
]
4 changes: 0 additions & 4 deletions examples/solid/config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,6 @@ config :volt,
]
]

config :volt, :format,
semi: false,
single_quote: true

config :volt, :lint,
plugins: ["typescript"],
tsgolint: System.find_executable("tsgolint"),
Expand Down
3 changes: 2 additions & 1 deletion examples/svelte/.formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"*.{heex,ex,exs}",
"{config,lib,test}/**/*.{heex,ex,exs}",
"assets/**/*.{js,ts,jsx,tsx}"
]
],
volt: [semi: false, single_quote: true]
]
4 changes: 0 additions & 4 deletions examples/svelte/config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,6 @@ config :volt,
]
]

config :volt, :format,
semi: false,
single_quote: true

config :volt, :lint,
plugins: ["typescript"],
tsgolint: System.find_executable("tsgolint"),
Expand Down
3 changes: 2 additions & 1 deletion examples/vanilla/.formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"*.{heex,ex,exs}",
"{config,lib,test}/**/*.{heex,ex,exs}",
"assets/**/*.{js,ts,jsx,tsx}"
]
],
volt: [semi: false, single_quote: true]
]
4 changes: 0 additions & 4 deletions examples/vanilla/config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,6 @@ config :volt,
]
]

config :volt, :format,
semi: false,
single_quote: true

config :volt, :lint,
plugins: ["typescript"],
tsgolint: System.find_executable("tsgolint"),
Expand Down
3 changes: 2 additions & 1 deletion examples/vue/.formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"*.{heex,ex,exs}",
"{config,lib,test}/**/*.{heex,ex,exs}",
"assets/**/*.{js,ts,jsx,tsx}"
]
],
volt: [semi: false, single_quote: true]
]
4 changes: 0 additions & 4 deletions examples/vue/config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,6 @@ config :volt,
]
]

config :volt, :format,
semi: false,
single_quote: true

config :volt, :lint,
plugins: ["typescript", "vue"],
tsgolint: System.find_executable("tsgolint"),
Expand Down
14 changes: 8 additions & 6 deletions guides/cheatsheets/configuration.cheatmd
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Prefix or list of prefixes exposed through `import.meta.env`.
config :volt, external: ~w(phoenix phoenix_html)
```

Exclude packages from the bundle.
Exclude packages from the bundle. IIFE output reads them from globals; `:esm` and `:cjs` keep the imports.

### Aliases

Expand Down Expand Up @@ -259,34 +259,36 @@ The dev-server Plug starts a supervised watcher on the first request. Set this t
## Formatting Options
{: .col-2}

Set under the `:volt` key of `.formatter.exs`.

### Semi

```elixir
config :volt, :format, semi: false
[volt: [semi: false]]
```

### Single Quote

```elixir
config :volt, :format, single_quote: true
[volt: [single_quote: true]]
```

### Print Width

```elixir
config :volt, :format, print_width: 100
[volt: [print_width: 100]]
```

### Trailing Comma

```elixir
config :volt, :format, trailing_comma: :none
[volt: [trailing_comma: :none]]
```

### Arrow Parens

```elixir
config :volt, :format, arrow_parens: :always
[volt: [arrow_parens: :always]]
```

## Lint Options
Expand Down
14 changes: 14 additions & 0 deletions guides/deployment/production-builds.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,20 @@ config :volt, external: ~w(phoenix phoenix_html phoenix_live_view)

Or per-build: `mix volt.build --external phoenix --external phoenix_html`

How an external is referenced depends on the output format, as in Rollup:

| Format | Output for `import { Socket } from "phoenix"` |
| --- | --- |
| `:iife` (default) | `const { Socket } = Phoenix;` — read from a global the page provides |
| `:esm` | `import { Socket } from "phoenix";` — resolved by the browser, for example through an import map |
| `:cjs` | `require("phoenix")` |

For IIFE output the global name is derived from the specifier (`phoenix_html` becomes `PhoenixHtml`). Pass a map to name the globals yourself; the names are ignored for `:esm` and `:cjs`:

```elixir
config :volt, external: %{"phoenix" => "Phoenix", "vue" => "Vue"}
```

## Module Preloading

For code-split builds, the production manifest records static imports, dynamic imports, chunk-local CSS, and emitted assets. Use `Volt.Preload.tags/2` in your layout to preload the entry and its static chunk dependencies:
Expand Down
13 changes: 13 additions & 0 deletions guides/features/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ Multiple prefixes are also supported:
config :volt, env_prefix: ["VOLT_", "PUBLIC_"]
```

## TypeScript

Volt's client types declare `import.meta.env` with `MODE`, `DEV`, `PROD`, and any other key as `string | boolean | undefined`. `mix igniter.install volt` adds them to `tsconfig.json`; to add them by hand, include `deps/volt/priv/types/client/**/*.d.ts`.

Declare your own variables to get exact types and completion:

```ts
// assets/env.d.ts
interface ImportMetaEnv {
readonly VOLT_API_URL: string
}
```

## File Loading Order

Files are loaded in order, with later files overriding earlier ones:
Expand Down
2 changes: 2 additions & 0 deletions guides/features/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ Exclude packages the host page already provides:
config :volt, external: ~w(phoenix phoenix_html phoenix_live_view)
```

IIFE output reads externals from globals; `:esm` and `:cjs` output keeps them as imports. See [Production Builds](../deployment/production-builds.md#external-modules).

## Source Maps

Production builds write `.map` files by default. Use `sourcemap: :hidden` to write maps without the URL comment (for Sentry, Datadog, etc.), or `sourcemap: false` to skip.
Expand Down
33 changes: 25 additions & 8 deletions guides/features/formatting-and-linting.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,35 @@ mix volt.js.format

### Configuration

Formatter options live under the `:volt` key of `.formatter.exs`, next to the plugin:

```elixir
config :volt, :format,
print_width: 100,
semi: false,
single_quote: true,
trailing_comma: :none,
arrow_parens: :always
[
plugins: [Volt.Formatter],
inputs: ["{config,lib,test}/**/*.{ex,exs}", "assets/**/*.{js,ts,jsx,tsx}"],
volt: [
print_width: 100,
semi: false,
single_quote: true,
trailing_comma: :none,
arrow_parens: :always
]
]
```

All [oxfmt options](https://hexdocs.pm/oxc/OXC.Format.html) are supported. Falls back to `.oxfmtrc.json` if no Elixir config is set.
All [oxfmt options](https://hexdocs.pm/oxc/OXC.Format.html) are supported. Without a `:volt` key, options come from `.oxfmtrc.json` or `.prettierrc.json`. `mix format`, `mix volt.js.format`, and `mix volt.js.check` all read the same options.

`mix format` formats the files matched by `:inputs`. `mix volt.js.format` and `mix volt.js.check` use the build source set; `:root`, `:sources`, and `:ignore` under the `:volt` key override it for formatting only:

```elixir
[
volt: [semi: false, sources: ["priv/ts/**/*.ts"], ignore: ["vendor/**"]]
]
```

`:root`, `:sources`, and `:ignore` may also be set under `config :volt, :format` to override the build source set for formatting only.
> #### Upgrading {: .info}
>
> Formatter options used to live in `config :volt, :format`. That key now only holds the build output format (`:iife`, `:esm`, or `:cjs`), and a keyword list there raises with instructions to move it.

## Linting

Expand Down
7 changes: 4 additions & 3 deletions guides/introduction/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ mix igniter.install volt
The installer:
- Adds `{:volt, "~> 0.19"}` to `mix.exs`
- Configures build settings in `config/config.exs`
- Adds format and lint config to `config/config.exs`
- Adds `Volt.Formatter` plugin to `.formatter.exs`
- Adds lint config to `config/config.exs`
- Adds the `Volt.Formatter` plugin and formatter options to `.formatter.exs`
- Adds the `Volt.DevServer` plug to your endpoint
- Configures Volt's automatic development watcher
- Updates `assets.build` and `assets.deploy` aliases
Expand Down Expand Up @@ -107,7 +107,8 @@ Add `Volt.Formatter` to `.formatter.exs`:
"{mix,.formatter}.exs",
"{config,lib,test}/**/*.{ex,exs}",
"assets/**/*.{js,ts,jsx,tsx}"
]
],
volt: [semi: false, single_quote: true]
]
```

Expand Down
3 changes: 2 additions & 1 deletion lib/mix/tasks/volt/build.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ defmodule Mix.Tasks.Volt.Build do
* `--sourcemap false` — skip source map generation
* `--sourcemap hidden` — write `.map` files but omit `sourceMappingURL` comment
* `--resolve-dir` — additional directory for bare specifier resolution (repeatable)
* `--external` — specifier to exclude from bundle (repeatable)
* `--external` — specifier to exclude from bundle (repeatable); read from a global
in `iife` output and kept as an import in `esm` and `cjs` output
* `--name` — output base name (default: derived from entry filename)
* `--no-hash` — stable filenames (no content hash)
* `--no-code-splitting` — disable chunk splitting
Expand Down
Loading
Loading