diff --git a/.reach.exs b/.reach.exs
index 95d1719..f4e26ce 100644
--- a/.reach.exs
+++ b/.reach.exs
@@ -102,6 +102,7 @@ logic = [
"Volt.JS.Check",
"Volt.JS.CommonJS",
"Volt.JS.Lint.Config",
+ "Volt.JS.Lint.TSConfigOverlay",
"Volt.JS.Extensions",
"Volt.JS.Format",
"Volt.JS.Discovery",
@@ -141,6 +142,7 @@ infrastructure = [
"Volt.Cache",
"Volt.Dev",
"Volt.Dev.Cleanup",
+ "Volt.Dev.Prebundled",
"Volt.Dev.Error",
"Volt.Priv.Vendor",
"Volt.DevServer.ClientTag",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c849de3..7969a2e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## Unreleased
+
+### Fixed
+
+- Leave Vue `
+
+ """)
+
+ Application.put_env(:volt, :sources, ["lib/**/*.vue"])
+ Application.put_env(:volt, :lint, tsgolint: @tsgolint, rules: %{})
+
+ output =
+ capture_io(:stderr, fn ->
+ catch_exit(Mix.Tasks.Volt.Js.Check.run(["--type-aware", "--type-check"]))
+ end)
+
+ # The script is type-checked, and with the project's options: the path
+ # alias and the `*.vue` declaration resolve.
+ assert output =~ "TS2322"
+ refute output =~ "TS2307"
+ refute output =~ "Cannot find module"
+ end
+
test "type-aware overrides batch by effective rules and use original SFC paths" do
files = Enum.map(["app.ts", "Component.vue", "Widget.svelte"], &Path.join(@tmp_dir, &1))
[app, vue, svelte] = files
diff --git a/test/volt/dev_server/import_rewriting_test.exs b/test/volt/dev_server/import_rewriting_test.exs
index 7f9452e..42df997 100644
--- a/test/volt/dev_server/import_rewriting_test.exs
+++ b/test/volt/dev_server/import_rewriting_test.exs
@@ -81,6 +81,36 @@ defmodule Volt.DevServer.ImportRewritingTest do
end
end
+ describe "syntax lowered for a target" do
+ @tag :tmp_dir
+ test "serves the runtime helper a lowered class field imports", %{tmp_dir: project} do
+ # A project of its own, so the helper is not already in the vendor cache.
+ root = Path.join(project, "src")
+ File.mkdir_p!(root)
+ File.mkdir_p!(Path.join(project, "node_modules"))
+
+ File.write!(Path.join(root, "search.ts"), """
+ export class Search {
+ private readonly style = 'field'
+ }
+ """)
+
+ # Class fields are lowered below ES2022, into an import of a helper from
+ # `@oxc-project/runtime`, which is not installed in `node_modules`.
+ opts = [root: root, target: :es2020]
+ conn = call_dev_server("/assets/search.ts", opts)
+ assert conn.status == 200
+
+ assert [helper_url] =
+ Regex.run(~r{/@vendor/[^"]*defineProperty\.js\?v=[a-f0-9]+}, conn.resp_body)
+
+ helper = call_dev_server(helper_url, opts)
+ assert helper.status == 200
+ assert helper.resp_body =~ "function _defineProperty"
+ assert helper.resp_body =~ ~r/export \{[^}]*default/
+ end
+ end
+
describe "import rewriting" do
test "rewrites relative imports to absolute paths" do
File.write!(Path.join(@fixture_dir, "src/utils.ts"), "export const y = 1")
diff --git a/test/volt/js/lint/tsconfig_overlay_test.exs b/test/volt/js/lint/tsconfig_overlay_test.exs
new file mode 100644
index 0000000..bfeb21e
--- /dev/null
+++ b/test/volt/js/lint/tsconfig_overlay_test.exs
@@ -0,0 +1,72 @@
+defmodule Volt.JS.Lint.TSConfigOverlayTest do
+ use ExUnit.Case, async: true
+
+ alias Volt.JS.Lint.TSConfigOverlay
+
+ @moduletag :tmp_dir
+
+ defp write!(root, path, content) do
+ path = Path.join(root, path)
+ File.mkdir_p!(Path.dirname(path))
+ File.write!(path, content)
+ path
+ end
+
+ test "adds virtual modules to the project of the nearest tsconfig", %{tmp_dir: root} do
+ original = """
+ {
+ // comments and trailing commas are allowed in a tsconfig
+ "compilerOptions": { "paths": { "@/*": ["./assets/js/*"] }, },
+ "include": ["assets/js/**/*.ts"],
+ }
+ """
+
+ tsconfig = write!(root, "tsconfig.json", original)
+ first = Path.join(root, "lib/app_web/Tally.vue.script0.ts")
+ second = Path.join(root, "assets/js/Card.vue.script0.ts")
+
+ overrides = TSConfigOverlay.overrides([first, second])
+
+ # The original is carried unchanged beside the tsconfig that replaces it.
+ assert overrides[Path.join(root, "tsconfig.volt-base.json")] == original
+
+ assert Jason.decode!(overrides[tsconfig]) == %{
+ "extends" => "./tsconfig.volt-base.json",
+ "files" => [second, first]
+ }
+ end
+
+ test "keeps the files the original names, which `files` would replace", %{tmp_dir: root} do
+ tsconfig = write!(root, "tsconfig.json", ~s({ files: ["src/main.ts", "./env.d.ts"] }))
+ virtual = Path.join(root, "src/App.vue.script0.ts")
+
+ assert %{"files" => ["src/main.ts", "./env.d.ts", ^virtual]} =
+ TSConfigOverlay.overrides([virtual])[tsconfig] |> Jason.decode!()
+ end
+
+ test "spells out the default include a tsconfig without one relies on", %{tmp_dir: root} do
+ tsconfig = write!(root, "tsconfig.json", ~s({ "compilerOptions": { "strict": true } }))
+ virtual = Path.join(root, "App.vue.script0.ts")
+
+ assert %{"include" => ["**/*"], "files" => [^virtual]} =
+ TSConfigOverlay.overrides([virtual])[tsconfig] |> Jason.decode!()
+ end
+
+ test "uses the nearest tsconfig for each virtual module", %{tmp_dir: root} do
+ outer = write!(root, "tsconfig.json", ~s({ "include": ["**/*"] }))
+ inner = write!(root, "assets/tsconfig.json", ~s({ "include": ["**/*"] }))
+ in_lib = Path.join(root, "lib/Page.vue.script0.ts")
+ in_assets = Path.join(root, "assets/js/App.vue.script0.ts")
+
+ overrides = TSConfigOverlay.overrides([in_lib, in_assets])
+
+ assert %{"files" => [^in_lib]} = Jason.decode!(overrides[outer])
+ assert %{"files" => [^in_assets]} = Jason.decode!(overrides[inner])
+ end
+
+ test "leaves virtual modules alone without a readable tsconfig above them", %{tmp_dir: root} do
+ write!(root, "broken/tsconfig.json", "not a config")
+
+ assert TSConfigOverlay.overrides([Path.join(root, "broken/App.vue.script0.ts")]) == %{}
+ end
+end
diff --git a/test/volt/js/vendor_test.exs b/test/volt/js/vendor_test.exs
index 45f8ff3..1d14363 100644
--- a/test/volt/js/vendor_test.exs
+++ b/test/volt/js/vendor_test.exs
@@ -73,6 +73,73 @@ defmodule Volt.JS.VendorTest do
"Pre-bundled"
end
+ @tag :tmp_dir
+ test "writes the cache under the project root while the working directory is elsewhere",
+ %{tmp_dir: elsewhere} do
+ # Mix changes the VM's working directory while it compiles a dependency,
+ # as Phoenix's code reloader does during a request.
+ {:ok, vendor_map} =
+ File.cd!(elsewhere, fn ->
+ Volt.JS.Vendor.prebundle(
+ root: Path.join(@fixture_dir, "src"),
+ node_modules: @node_modules,
+ force: true
+ )
+ end)
+
+ assert File.ls!(elsewhere) == []
+
+ for {_specifier, path} <- vendor_map do
+ assert String.starts_with?(path, Volt.Paths.expand("_build"))
+ assert File.regular?(path)
+ end
+
+ assert {:ok, _code} =
+ File.cd!(elsewhere, fn ->
+ Volt.JS.Vendor.read("fake-lib", node_modules: @node_modules)
+ end)
+ end
+
+ test "an import that resolves to no package does not bundle the others again" do
+ File.write!(
+ Path.join(@fixture_dir, "src/provided.ts"),
+ "import { value } from 'fake-lib'\nimport host from 'provided-by-the-page'\nconsole.log(value, host)"
+ )
+
+ on_exit(fn -> File.rm(Path.join(@fixture_dir, "src/provided.ts")) end)
+ opts = [root: Path.join(@fixture_dir, "src"), node_modules: @node_modules]
+
+ {:ok, vendor_map} = Volt.JS.Vendor.prebundle(Keyword.put(opts, :force, true))
+ assert Map.has_key?(vendor_map, "fake-lib")
+ refute Map.has_key?(vendor_map, "provided-by-the-page")
+
+ refute ExUnit.CaptureLog.capture_log(fn -> Volt.JS.Vendor.prebundle(opts) end) =~
+ "Pre-bundled"
+ end
+
+ test "is not repeated on every request until the sources are said to have changed" do
+ opts = [root: Path.join(@fixture_dir, "src"), node_modules: @node_modules]
+
+ prebundle = fn ->
+ Volt.Dev.Prebundled.ensure(opts, fn -> Volt.JS.Vendor.prebundle(opts) end)
+ end
+
+ Volt.Dev.Prebundled.forget()
+ on_exit(&Volt.Dev.Prebundled.forget/0)
+
+ :ok = prebundle.()
+ [cached] = Path.wildcard("_build/volt/vendor/*/fake-lib.js")
+ File.rm!(cached)
+
+ # Asked again, as on every request: nothing is scanned or bundled.
+ :ok = prebundle.()
+ refute File.exists?(cached)
+
+ Volt.Dev.Prebundled.forget()
+ :ok = prebundle.()
+ assert File.exists?(cached)
+ end
+
test "caches bundled files on disk" do
Volt.JS.Vendor.prebundle(
root: Path.join(@fixture_dir, "src"),
diff --git a/test/volt/plugin/vue_test.exs b/test/volt/plugin/vue_test.exs
index 1577d9c..a1d5328 100644
--- a/test/volt/plugin/vue_test.exs
+++ b/test/volt/plugin/vue_test.exs
@@ -12,6 +12,46 @@ defmodule Volt.Plugin.VueTest do
assert result.code =~ "export default { render }"
end
+ describe "embedded_modules/3" do
+ defp script_modules(source) do
+ "Example.vue"
+ |> Volt.Plugin.Vue.embedded_modules(source, [])
+ |> Volt.Plugin.EmbeddedModule.normalize_all()
+ |> Enum.filter(&(&1.type == :script))
+ |> Enum.map(&{&1.extension, String.trim(&1.source)})
+ end
+
+ test "leaves out script blocks that are not JavaScript" do
+ source = """
+
+
+
+
+ {{ n }}
+ """
+
+ assert script_modules(source) == [{".ts", "const n: number = 1"}]
+ end
+
+ test "names script blocks by their JavaScript dialect" do
+ for {lang, extension} <- [
+ {"", ".js"},
+ {~s( lang="js"), ".js"},
+ {~s( lang="jsx"), ".jsx"},
+ {~s( lang="ts"), ".ts"},
+ {~s( lang="tsx"), ".tsx"}
+ ] do
+ source = ""
+
+ assert script_modules(source) == [{extension, "export default {}"}]
+ end
+ end
+ end
+
test "script-bearing scoped components receive their CSS scope on the default export" do
for script <- [
"",
diff --git a/test/volt/watcher_test.exs b/test/volt/watcher_test.exs
index c8b5d91..274178e 100644
--- a/test/volt/watcher_test.exs
+++ b/test/volt/watcher_test.exs
@@ -357,6 +357,24 @@ defmodule Volt.WatcherTest do
GenServer.stop(pid)
end
+ test "a changed source makes the next request scan for new package imports", %{
+ watch_dir: watch_dir
+ } do
+ source = Path.join(watch_dir, "app.ts")
+ File.write!(source, "export const x = 1")
+ opts = [root: watch_dir, node_modules: Path.join(watch_dir, "node_modules")]
+
+ :ok = Volt.Dev.Prebundled.ensure(opts, fn -> :ok end)
+ assert :ets.lookup(:volt_vendor_prebundled, opts) != []
+
+ {:ok, pid} = Volt.Watcher.start_link(root: watch_dir, name: :test_watcher_forget_prebundled)
+ state = send_file_event(pid, source)
+ Process.cancel_timer(state.pending[source])
+
+ assert :ets.lookup(:volt_vendor_prebundled, opts) == []
+ GenServer.stop(pid)
+ end
+
test "ignores configured watcher paths", %{watch_dir: watch_dir} do
generated_dir = Path.join(watch_dir, ".generated")
entry = Path.join(generated_dir, "entry.ts")