diff --git a/CHANGELOG.md b/CHANGELOG.md index 1474bd9..4306522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/mix/tasks/volt/install.ex b/lib/mix/tasks/volt/install.ex index 9abc231..f643403 100644 --- a/lib/mix/tasks/volt/install.ex +++ b/lib/mix/tasks/volt/install.ex @@ -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 @@ -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 diff --git a/priv/templates/volt.install/tsconfig.json.eex b/priv/templates/volt.install/tsconfig.json.eex new file mode 100644 index 0000000..0aa0c0f --- /dev/null +++ b/priv/templates/volt.install/tsconfig.json.eex @@ -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"] +} diff --git a/priv/types/client/styles.d.ts b/priv/types/client/styles.d.ts new file mode 100644 index 0000000..38f6005 --- /dev/null +++ b/priv/types/client/styles.d.ts @@ -0,0 +1,6 @@ +declare module "*.module.css" { + const classes: Readonly>; + export default classes; +} + +declare module "*.css" {} diff --git a/test/mix/tasks/volt/install_test.exs b/test/mix/tasks/volt/install_test.exs index edf3c53..744d377 100644 --- a/test/mix/tasks/volt/install_test.exs +++ b/test/mix/tasks/volt/install_test.exs @@ -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