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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Added

- Wire shared browser declarations into TypeScript configurations during installation. Create a configuration when absent, update ordinary JSON configurations without discarding existing settings, and provide manual guidance for JSONC and inherited or referenced projects.

## 0.18.0 - 2026-09-15

### Breaking changes
Expand Down
69 changes: 69 additions & 0 deletions lib/mix/tasks/volt/install.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ if Code.ensure_loaded?(Igniter) do
6. Add `Volt.Formatter` plugin to `.formatter.exs`
7. Add `Volt.DevServer` plug to your endpoint
8. Configure the automatic Volt watcher in `config/dev.exs`
9. Wire Volt client types into TypeScript configuration

The installer checks `tsconfig.json`, `assets/tsconfig.json`, and
`assets/js/tsconfig.json`. Ordinary JSON configurations retain their settings
and receive explicit declaration files, even when dependencies are excluded.
When none exists, the installer creates a root configuration from a template.

JSONC and configurations using `extends` or project references are left
unchanged with guidance to include `deps/volt/priv/types/client/**/*.d.ts`
(relative to the browser configuration). No local `ImportMeta` declaration
is needed.
"""

use Igniter.Mix.Task
Expand Down Expand Up @@ -58,10 +69,68 @@ if Code.ensure_loaded?(Igniter) do
|> add_format_config()
|> add_lint_config()
|> add_formatter_plugin()
|> add_typescript_config()
|> add_dev_config(app_name, endpoint)
|> add_dev_server_plug()
end

defp add_typescript_config(igniter) do
configs = [
{"tsconfig.json", "deps"},
{"assets/tsconfig.json", "../deps"},
{"assets/js/tsconfig.json", "../../deps"}
]

existing = Enum.filter(configs, fn {path, _} -> Igniter.exists?(igniter, path) end)

case existing do
[] ->
template = Application.app_dir(:volt, "priv/templates/volt.install/tsconfig.json.eex")
Igniter.copy_template(igniter, template, "tsconfig.json", [])

paths ->
Enum.reduce(paths, igniter, fn {path, deps}, igniter ->
Igniter.update_file(igniter, path, &include_client_types(&1, deps))
end)
end
end

defp include_client_types(source, deps) do
with {:ok, config} when is_map(config) <- Jason.decode(Rewrite.Source.get(source, :content)),
false <- Map.has_key?(config, "extends") or Map.has_key?(config, "references"),
files when is_list(files) <- Map.get(config, "files", []),
true <- Enum.all?(files, &is_binary/1) do
types = [
"#{deps}/volt/priv/types/client/hmr.d.ts",
"#{deps}/volt/priv/types/client/preload.d.ts",
"#{deps}/volt/priv/types/client/styles.d.ts"
]

updated =
config
|> preserve_default_include()
|> Map.put("files", Enum.uniq(files ++ types))

if updated == config do
source
else
Rewrite.Source.update(source, :content, Jason.encode!(updated, pretty: true) <> "\n")
end
else
_ ->
{:warning,
"Volt left #{source.path} unchanged: JSONC, inherited and referenced projects require " <>
"manual configuration. Include #{deps}/volt/priv/types/client/**/*.d.ts in the browser " <>
"project, ensuring it is not excluded. No application-local ImportMeta declaration is needed."}
end
end

defp preserve_default_include(config) do
if Map.has_key?(config, "files") or Map.has_key?(config, "include"),
do: config,
else: Map.put(config, "include", ["**/*"])
end

# ── Remove old tooling ──

defp remove_old_deps(igniter) do
Expand Down
12 changes: 12 additions & 0 deletions priv/templates/volt.install/tsconfig.json.eex
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"noEmit": true,
"strict": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["assets/**/*", "deps/volt/priv/types/client/**/*.d.ts"]
}
6 changes: 6 additions & 0 deletions priv/types/client/styles.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
declare module "*.module.css" {
const classes: Readonly<Record<string, string>>;
export default classes;
}

declare module "*.css" {}
77 changes: 77 additions & 0 deletions test/mix/tasks/volt/install_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,86 @@ defmodule Mix.Tasks.Volt.InstallTest do
igniter.rewrite.sources["mix.exs"]
|> Rewrite.Source.get(:content)

tsconfig =
igniter.rewrite.sources["tsconfig.json"]
|> Rewrite.Source.get(:content)
|> Jason.decode!()

assert tsconfig["compilerOptions"]["strict"]
assert "deps/volt/priv/types/client/**/*.d.ts" in tsconfig["include"]

assert mix_content =~ ~s("assets.setup": [])
assert mix_content =~ ~s("assets.build": ["compile", "volt.build --tailwind"])
assert mix_content =~ ~s("assets.deploy": ["volt.build --tailwind", "phx.digest"])
end

test "preserves options, exclusions and explicit files while adding client types once" do
original = %{
"compilerOptions" => %{"strict" => false},
"exclude" => ["deps"],
"files" => ["assets/app.ts"]
}

igniter = install_with_config("tsconfig.json", Jason.encode!(original))
config = config(igniter, "tsconfig.json")
assert config["compilerOptions"] == original["compilerOptions"]
assert config["exclude"] == ["deps"]
refute Map.has_key?(config, "include")

assert config["files"] == [
"assets/app.ts",
"deps/volt/priv/types/client/hmr.d.ts",
"deps/volt/priv/types/client/preload.d.ts",
"deps/volt/priv/types/client/styles.d.ts"
]

assert config(Mix.Tasks.Volt.Install.igniter(igniter), "tsconfig.json") == config
end

test "retains default source discovery when adding explicit declaration files" do
config =
install_with_config("tsconfig.json", ~s({"compilerOptions":{"strict":true}}))
|> config("tsconfig.json")

assert config["include"] == ["**/*"]
end

test "keeps an authored include list" do
config =
install_with_config("tsconfig.json", ~s({"include":["src/**/*.ts"]}))
|> config("tsconfig.json")

assert config["include"] == ["src/**/*.ts"]
end

test "updates an assets configuration with paths relative to that configuration" do
igniter = install_with_config("assets/tsconfig.json", ~s({"include":["**/*.ts"]}))
refute Map.has_key?(igniter.rewrite.sources, "tsconfig.json")

assert "../deps/volt/priv/types/client/hmr.d.ts" in config(igniter, "assets/tsconfig.json")[
"files"
]
end

test "leaves JSONC and inherited or referenced configurations untouched with guidance" do
for content <- [
"{ // preserve this comment\n}",
~s({"extends":"./base.json"}),
~s({"references":[{"path":"./assets"}]})
] do
igniter = install_with_config("tsconfig.json", content)
assert Rewrite.Source.get(igniter.rewrite.sources["tsconfig.json"], :content) == content
assert Enum.any?(igniter.warnings, &String.contains?(&1, "manual configuration"))
end
end
end

defp install_with_config(path, content) do
Test.test_project(app_name: :demo, files: %{path => content})
|> Mix.Tasks.Volt.Install.igniter()
end

defp config(igniter, path) do
igniter.rewrite.sources[path] |> Rewrite.Source.get(:content) |> Jason.decode!()
end
end
Loading