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
1 change: 1 addition & 0 deletions .reach.exs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ model = [
logic = [
"Volt.Builder.Artifact",
"Volt.Builder.CSS.Prepared",
"Volt.Builder.Declarations",
"Volt.Builder.Plan",
"Volt.Assets",
"Volt.Assets.Query",
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

### Added

- `mix volt.build --declarations` and `config :volt, declarations: true` write a library's type declarations with its bundle: one `<name>.d.ts` per entry, beside it and without a hash, holding what the entry exports and the types those exports reach. Modules are emitted through OXC's isolated declarations (oxc 0.19), so exported declarations need explicit types and the build fails naming the module that lacks them; the declarations are then bundled as `rollup-plugin-dts` does, with types-only modules included, imports of other packages hoisted, and same-named declarations of different modules told apart. `.vue` modules are declared by the Vue plugin ([#71](https://github.com/elixir-volt/volt/issues/71)).

### Changed

- Require oxc 0.19 and quickbeam 0.11.3.

## 0.22.1 - 2026-10-07

### Fixed
Expand Down
12 changes: 12 additions & 0 deletions guides/deployment/production-builds.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,18 @@ For IIFE output the global name is derived from the specifier (`phoenix_html` be
config :volt, external: %{"phoenix" => "Phoenix", "vue" => "Vue"}
```

## Type Declarations for Libraries

A library that ships a JavaScript client can write its type declarations with its bundle:

```elixir
"volt.build --entry priv/ts/client/my_lib.ts --format esm --name my_lib --declarations"
```

or `config :volt, declarations: true`. Each entry gets one `my_lib.d.ts` beside its bundle, without a hash, holding what the entry exports and the types those exports refer to, including types from modules that only hold types. Imports of other packages stay imports, for the consumer's `node_modules` to resolve. Point `package.json`'s `types` at the file.

Declarations are emitted per module without a type checker, as `tsc --isolatedDeclarations` does, so exported declarations need explicit types: a function without a return type or a `const` whose type is inferred fails the build, naming the module and line. Set `"isolatedDeclarations": true` in `tsconfig.json` to have the editor and `mix volt.js.check --type-aware` report the same places. Two modules declaring the same name are told apart with a `$1` suffix on the one further from the entry; `import * as` and `export * as` of the library's own modules are not bundled.

## 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
9 changes: 9 additions & 0 deletions lib/mix/tasks/volt/build.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ defmodule Mix.Tasks.Volt.Build do
* `--no-tree-shaking` — preserve unused exports
* `--mode` — build mode for env variables (default: `"production"`)
* `--format` — output format: `iife`, `esm`, or `cjs` (default from config)
* `--declarations` — write one `.d.ts` per entry beside its bundle, for a library
* `--tailwind` — build Tailwind CSS
* `--tailwind-css` — custom Tailwind input CSS file
* `--tailwind-source` — source directory for Tailwind scanning (repeatable)
Expand Down Expand Up @@ -61,6 +62,7 @@ defmodule Mix.Tasks.Volt.Build do
resolve_dir: [:string, :keep],
external: [:string, :keep],
code_splitting: :boolean,
declarations: :boolean,
tree_shaking: :boolean,
tailwind: :boolean,
tailwind_css: :string,
Expand Down Expand Up @@ -127,6 +129,7 @@ defmodule Mix.Tasks.Volt.Build do
mode: Keyword.get(parsed, :mode) || to_string(config.mode),
format: parse_format(Keyword.get(parsed, :format), config.format),
code_splitting: Keyword.get(parsed, :code_splitting, config.code_splitting),
declarations: Keyword.get(parsed, :declarations, config.declarations),
tree_shaking: Keyword.get(parsed, :tree_shaking, config.tree_shaking),
chunks: config.chunks,
env_prefix: config.env_prefix,
Expand Down Expand Up @@ -176,6 +179,12 @@ defmodule Mix.Tasks.Volt.Build do
Mix.shell().info(" #{Path.basename(style.path)} #{format_file(style.path)}")
end

for declaration <- assets.declarations do
Mix.shell().info(
" #{Path.basename(declaration.path)} #{format_file(declaration.path)}"
)
end

Mix.shell().info(" manifest.json #{map_size(manifest)} entries")
Mix.shell().info("Built in #{ms}ms")

Expand Down
52 changes: 49 additions & 3 deletions lib/volt/builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ defmodule Volt.Builder do
* `:asset_url_prefix` — public URL prefix for emitted asset references (default: `"/assets"`)
* `:code_splitting` — split dynamic imports and multi-entry ESM shared modules into chunks (default: `true`)
* `:tree_shaking` — remove unused exports (default: `true`)
* `:declarations` — write one `.d.ts` per script entry beside its bundle, with the types the entry exports (default: `false`). See `Volt.Builder.Declarations`.
* `:chunks` — manual chunk definitions, map of chunk name to list of patterns:

chunks: %{"vendor" => ["vue", "vue-router"], "ui" => ["assets/src/components"]}
Expand Down Expand Up @@ -152,7 +153,13 @@ defmodule Volt.Builder do
build_isolated_entries(entries, ctx, build_ctx) |> finalize_build_results()

result ->
merge_worker_metadata(result, worker_metadata)
result
|> merge_worker_metadata(worker_metadata)
|> with_declarations(
Enum.map(entries, fn {entry, :script, name} -> {entry, name} end),
ctx,
build_ctx
)
end
else
true -> build_isolated_entries(entries, ctx, build_ctx) |> finalize_build_results()
Expand Down Expand Up @@ -336,7 +343,9 @@ defmodule Volt.Builder do
Output.prepare_single(entry, name, compiled, out)
end

merge_worker_metadata(prepared, worker_metadata)
prepared
|> merge_worker_metadata(worker_metadata)
|> with_declarations([{entry, name}], ctx, build_ctx)
end
end

Expand Down Expand Up @@ -398,6 +407,7 @@ defmodule Volt.Builder do
asset_url_prefix = Keyword.get(opts, :asset_url_prefix, Paths.prefix())
aliases = Keyword.get(opts, :aliases, %{})
code_splitting = Keyword.get(opts, :code_splitting, true)
declarations = Keyword.get(opts, :declarations, false)
tree_shaking = Keyword.get(opts, :tree_shaking, true)
chunks = Keyword.get(opts, :chunks, %{})
format = Keyword.get(opts, :format, :iife)
Expand Down Expand Up @@ -463,6 +473,7 @@ defmodule Volt.Builder do
bundle_opts: bundle_opts,
asset_url_prefix: asset_url_prefix,
code_splitting: code_splitting,
declarations: declarations,
sourcemap_hidden: sourcemap_opt == :hidden,
chunks: chunks
}
Expand Down Expand Up @@ -515,6 +526,39 @@ defmodule Volt.Builder do
end
end

# A library's types go beside its bundle, under the entry's name and no hash:
# `package.json` names the file.
defp with_declarations({:ok, result, plan}, entries, ctx, %{declarations: true} = build_ctx) do
Enum.reduce_while(entries, {:ok, [], []}, fn {entry, name}, {:ok, outputs, artifacts} ->
case Volt.Builder.Declarations.bundle(entry, ctx) do
{:ok, dts} ->
file = "#{name}.d.ts"
artifact = %Volt.Builder.Artifact{file: file, content: dts}

output = %Volt.Builder.OutputFile{
path: Path.join(build_ctx.outdir, file),
size: byte_size(dts)
}

{:cont, {:ok, [output | outputs], [artifact | artifacts]}}

error ->
{:halt, error}
end
end)
|> case do
{:ok, outputs, artifacts} ->
with {:ok, plan} <- Volt.Builder.Plan.new(plan.artifacts ++ Enum.reverse(artifacts)) do
{:ok, %{result | declarations: result.declarations ++ Enum.reverse(outputs)}, plan}
end

error ->
error
end
end

defp with_declarations(prepared, _entries, _ctx, _build_ctx), do: prepared

defp merge_worker_metadata({:error, _} = error, _workers), do: error

defp merge_worker_metadata({:ok, result, plan}, workers) do
Expand Down Expand Up @@ -966,6 +1010,7 @@ defmodule Volt.Builder do
css: result.css || acc.css,
styles: [result.styles | acc.styles],
chunks: [result.chunks | acc.chunks],
declarations: [result.declarations | acc.declarations],
manifest: Map.merge(acc.manifest, result.manifest)
}

Expand All @@ -989,7 +1034,8 @@ defmodule Volt.Builder do
result
| js: js,
styles: result.styles |> Enum.reverse() |> List.flatten() |> Enum.uniq(),
chunks: result.chunks |> Enum.reverse() |> List.flatten()
chunks: result.chunks |> Enum.reverse() |> List.flatten(),
declarations: result.declarations |> Enum.reverse() |> List.flatten()
}, plan}
end

Expand Down
1 change: 1 addition & 0 deletions lib/volt/builder/build_context.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ defmodule Volt.Builder.BuildContext do
ctx: nil,
asset_url_prefix: Volt.Paths.prefix(),
code_splitting: true,
declarations: false,
sourcemap_hidden: false,
chunks: %{}
end
18 changes: 18 additions & 0 deletions lib/volt/builder/context.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,22 @@ defmodule Volt.Builder.Context do
asset_url_prefix: Volt.Paths.prefix(),
asset_outdir: nil,
asset_root: nil

@type t :: %__MODULE__{
node_modules: Path.t() | nil,
resolve_dirs: [Path.t()],
package_scopes: list(),
aliases: %{String.t() => String.t()},
plugins: [module() | {module(), keyword()}],
external: MapSet.t(String.t()),
external_globals: %{String.t() => String.t()},
loaders: map(),
module_types: map(),
import_source: String.t() | nil,
target: String.t(),
define: map(),
asset_url_prefix: String.t(),
asset_outdir: Path.t() | nil,
asset_root: Path.t() | nil
}
end
Loading
Loading