diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b66b59e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +priv/ts/npm.lock linguist-generated diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8d5327..786f701 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: sudo apt-get update sudo apt-get install --yes inotify-tools mix deps.get + mix volt.priv.vendor priv/ts npm ci npx playwright install --with-deps chromium latest-command: MIX_ENV=dev mix lint && MIX_ENV=test mix test diff --git a/.gitignore b/.gitignore index c6598cf..21b1a10 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ volt-*.tar # Git worktrees for parallel agent work .worktrees/ +/priv/ts/node_modules/ diff --git a/.reach.exs b/.reach.exs index 8eabe9f..3556620 100644 --- a/.reach.exs +++ b/.reach.exs @@ -139,6 +139,9 @@ infrastructure = [ "Volt.Cache", "Volt.Dev", "Volt.Dev.Cleanup", + "Volt.Dev.Error", + "Volt.Priv.Vendor", + "Volt.DevServer.ClientTag", "Volt.Dev.Assets", "Volt.Dev.Session.Call", "Volt.Dev.Session.State", @@ -147,6 +150,7 @@ infrastructure = [ "Volt.Dev.ConsoleForwarder.Payload", "Volt.ETS", "Volt.HMR.Boundary", + "Volt.HMR.Errors", "Volt.HMR.GlobGraph", "Volt.HMR.ImportGraph", "Volt.HMR.ModuleGraph", diff --git a/CHANGELOG.md b/CHANGELOG.md index 053df36..c18b6c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ ## Unreleased +### Breaking changes + +- Lint diagnostics are `OXC.Diagnostic` maps: severities are `:error` and `:warning`, and locations are 1-based `position` and `span` instead of byte offsets. Parse errors are diagnostics too, without a `rule`. +- Lint `:plugins`, `:env`, and `:globals` take names as strings, as in an oxlint config file, such as `plugins: ["typescript", "react"]` and `env: ["browser"]`. Atom names raise an `ArgumentError`. +- Custom lint rules report findings with AST `start` and `end` offsets instead of `span`. +- `Volt.JS.Transforms.Imports.rewrite!/3` raises `OXC.Error` on parse errors. +- HMR `error` messages carry the current `errors` as `Volt.Dev.Error` entries instead of a `path` and `reason`; an empty list hides the overlay. + +### Added + +- The error overlay shows each error's file, line, column, source frame, and hint. +- The error overlay is a `` element rendered with lit-html into a Shadow DOM, so the app's styles cannot affect it. +- Highlight the error overlay's source frame with the optional `lumis` dependency and its parser packages, such as `lumis_wasm_typescript`. +- `Volt.HMR.error/3` takes a `:title` for the overlay heading, such as `"Render error"`. `Volt.HMR.clear_error/2` hides errors reported with `Volt.HMR.error/3`. Errors stay current until cleared, so browsers that connect later show them. +- The dev server adds the HMR client to HTML pages the app renders. +- `mix volt.priv.vendor` vendors npm packages for browser code under `priv`, keeping only the files the sources reach, for the Hex package rather than git. `Volt.Priv.bundle!/3` bundles a `priv` entry with them and vendors on first use in a git or path checkout. See "Browser code with npm packages" in the plugins guide. +- The error overlay shows a diagnostic's stacktrace. + +### Changed + +- Require `oxc` 0.18.1, `vize` 0.15, `quickbeam` 0.11.2, and `json_codec` 0.3. + +### Fixed + +- Show the error overlay when a page loads with a module that fails to compile. The client previously loaded only through the failing module graph. +- Hide the overlay once errors are fixed, and report Tailwind build failures, including at startup. +- Reload the page after the HMR connection returns, since the server may have restarted. +- Type-aware checks run the type-aware rules that lint categories such as `"correctness"` select, honoring individual rule settings and per-file overrides ([#40](https://github.com/elixir-volt/volt/issues/40)). +- Type-aware checks extract single-file component scripts with the configured Volt plugins instead of the oxlint plugin names. + ## 0.18.1 - 2026-09-15 ### Added diff --git a/config/config.exs b/config/config.exs index 98a050e..98e14d3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -9,7 +9,7 @@ config :volt, :format, arrow_parens: :always config :volt, :lint, - plugins: [:typescript, :import, :unicorn], + plugins: ["typescript", "import", "unicorn"], rules: %{ "no-unused-expressions" => :allow, "no-unused-vars" => :warn, diff --git a/examples/react/config/config.exs b/examples/react/config/config.exs index fbb5c48..8c8f13d 100644 --- a/examples/react/config/config.exs +++ b/examples/react/config/config.exs @@ -38,7 +38,7 @@ config :volt, :format, single_quote: true config :volt, :lint, - plugins: [:typescript, :react], + plugins: ["typescript", "react"], tsgolint: System.find_executable("tsgolint"), rules: %{ "correctness" => :deny, diff --git a/examples/solid/config/config.exs b/examples/solid/config/config.exs index d40c8a5..595d9f0 100644 --- a/examples/solid/config/config.exs +++ b/examples/solid/config/config.exs @@ -38,7 +38,7 @@ config :volt, :format, single_quote: true config :volt, :lint, - plugins: [:typescript], + plugins: ["typescript"], tsgolint: System.find_executable("tsgolint"), rules: %{ "correctness" => :deny, diff --git a/examples/svelte/config/config.exs b/examples/svelte/config/config.exs index 8cc9c41..e367836 100644 --- a/examples/svelte/config/config.exs +++ b/examples/svelte/config/config.exs @@ -37,7 +37,7 @@ config :volt, :format, single_quote: true config :volt, :lint, - plugins: [:typescript], + plugins: ["typescript"], tsgolint: System.find_executable("tsgolint"), rules: %{ "correctness" => :deny, diff --git a/examples/vanilla/config/config.exs b/examples/vanilla/config/config.exs index d4840f1..aba95e3 100644 --- a/examples/vanilla/config/config.exs +++ b/examples/vanilla/config/config.exs @@ -37,7 +37,7 @@ config :volt, :format, single_quote: true config :volt, :lint, - plugins: [:typescript], + plugins: ["typescript"], tsgolint: System.find_executable("tsgolint"), rules: %{ "correctness" => :deny, diff --git a/examples/vanilla/mix.exs b/examples/vanilla/mix.exs index 4c47cac..9f98dbc 100644 --- a/examples/vanilla/mix.exs +++ b/examples/vanilla/mix.exs @@ -36,7 +36,9 @@ defmodule VanillaExample.MixProject do {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, {:bandit, ">= 1.12.5 and < 2.0.0"}, - {:volt, path: "../..", override: true} + {:volt, path: "../..", override: true}, + {:lumis, "~> 0.10", only: :dev}, + {:lumis_wasm_typescript, "~> 0.26", only: :dev} ] end diff --git a/examples/vanilla/mix.lock b/examples/vanilla/mix.lock index 9c4872f..c74d820 100644 --- a/examples/vanilla/mix.lock +++ b/examples/vanilla/mix.lock @@ -4,23 +4,25 @@ "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, + "finch": {:hex, :finch, "0.24.0", "4022b6194e907b6d9b597c168001cac38e488a35fe36cef9d8297e3159782510", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "33ba40069c3587c2f99f9125b766e19dad87d6d54be3c6961db2304df04cef00"}, "floki": {:hex, :floki, "0.38.1", "f002ccac94b3bcb21d40d9b34cc2cc9fd88a8311879120330075b5dde657ebee", [:mix], [], "hexpm", "e744bf0db7ee34b2c8b62767f04071107af0516a81144b9a2f73fe0494200e5b"}, "glob_ex": {:hex, :glob_ex, "0.1.12", "7b2d9369c20e2697efcfd185d13d6e84c94cd3bfd2730fbde613141c2e015c00", [:mix], [], "hexpm", "2e2fac83f113514434c7eaf267b4c38af2f91766f1cab2c5db7053b7fc1ee0bb"}, "hex_solver": {:hex, :hex_solver, "0.3.0", "81e7659ad6caba1f856d89fc7ca52f88e8eb4f801deedcdce215f9006d706d1d", [:mix], [], "hexpm", "8a04c8ef0df25ca1f5e4d7d5f32833fac569f5c6442f05e216ee33615593e64d"}, - "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, - "json_codec": {:hex, :json_codec, "0.2.5", "4463d9db729ba994c356a0598b9a88ec0956a77e7ab778318d128b43db665faa", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_spec, "~> 1.2", [hex: :json_spec, repo: "hexpm", optional: false]}], "hexpm", "b32f189249cda0954a5fb6f4aab720444b5f3f81272869ce90098eea2418d465"}, + "json_codec": {:hex, :json_codec, "0.3.1", "81f738e212308c85344e11da00af6d8b52bd486224ee24c1f51b95df0604aeae", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:json_spec, "~> 1.2", [hex: :json_spec, repo: "hexpm", optional: false]}], "hexpm", "dffea3198d3f1fe9a39aa176ab0d6988b8c80dd85a67e913b323d4ec7c57ca9d"}, "json_spec": {:hex, :json_spec, "1.2.0", "c67f73cdc4166e955b2f1095d3f44dd78c197337038f1a5301fb526b6c87ab04", [:mix], [], "hexpm", "3639961babad7d6bc45f3a2d96a49c18ec70614761bb84ce58b64e9fad9354de"}, + "lumis": {:hex, :lumis, "0.10.0", "4b0b773447f7dace8be7b0c93abe4d24e9b7c81034ac2757003f3ac4b7ac3450", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "5e3845defa32871a404d6a51ad2d8d3c423077f6f80149e632511f26460f27dc"}, + "lumis_wasm_typescript": {:hex, :lumis_wasm_typescript, "0.26.2", "6f6972ba900860bfa5b601333a216faa24a9e1f59dac84b2beae3911941b006b", [:mix], [], "hexpm", "277acc22eb48133299e137ba5f127bf635409192dd5f5c6f5839e52fd7c0bc72"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.10.0", "85af3353bfc504f5bdfe494bd92b8490f87a306dc659ee1ad0af435107e898dc", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "8b16fb72aaa7531d206a1f05e4cc85509ba531ccec7a17a22736c9c95cbb24d1"}, + "mint": {:hex, :mint, "1.11.0", "a713551624815c0435237b93d90ea8b9b14254690c66f732d0ef8930f76ff1d9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "c6279ba2d6aa3a383a1d4cfbe7b59f42e6efd400f58d8e2acfeac48a438693ab"}, "mint_web_socket": {:hex, :mint_web_socket, "1.0.6", "5ffcf350df5b90f2d7a04adf877165228804993714592512374218d4679e325a", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "0c360e9012413f1c115a63532601eb5d63731aab7010949178769760686c1698"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "npm": {:hex, :npm, "0.7.6", "de8ca3dd26a40fac7ab8a17680088b8d55bab0e0294be9770b029c276361482f", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:npm_semver, "~> 0.1.0", [hex: :npm_semver, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "e0a1ce77e3e49ec108e095aba133bd315e86da298dba26b142a105972141d9ef"}, "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, - "oxc": {:hex, :oxc, "0.17.8", "e4c50ee0c0ce09107016b5153132b3d98421665355189b531c78ae4603047853", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "590fddfadbf843d6ab94e0f04a0e769a38358a23140728095da476c8c81f4df1"}, + "oxc": {:hex, :oxc, "0.18.0", "0b22ae1ac62f6e985458aae53bb0020756bd790ed07bcda1a89168b266025415", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "121495bbbaba0807433653d4446cb9ed68bc7db35e6f11a9c82da56f89bcaa6c"}, "oxide_ex": {:hex, :oxide_ex, "0.2.2", "063e0d2899e81632b7efc7849eb1dd89a7a623290a075c446b43bef14cf5795e", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "577a2fbd97c46904a31143bb700cdb374105085cfd1f7605416bb6c012cb7f60"}, "phoenix": {:hex, :phoenix, "1.8.14", "9279cbbcd755ac8d5f42d206915e7df13b9ab49096b98114f1c99ff908e5b42e", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "2782ff375824b2b5e41561fbae4764ee7b875af6898483bca49f24a9d1e37816"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, @@ -30,15 +32,15 @@ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, - "quickbeam": {:hex, :quickbeam, "0.11.1", "55e7700b2a16c8df246402f8400908222e906f3b463af4ac8706f748d83b66db", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.10", [hex: :mint, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.6", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.17.8", [hex: :oxc, repo: "hexpm", optional: false]}, {:varint, "~> 1.6", [hex: :varint, repo: "hexpm", optional: false]}, {:zigler, "~> 0.16.0", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.6", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "6c0240b9d016bbc174319ef004e81f2893ae2e77939d6b9b87c5542b344d0b87"}, + "quickbeam": {:hex, :quickbeam, "0.11.2", "ea4e4bfd7a8999381f86b773b13d028c02753137f8b56c1d3f3cce4649dfee71", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: true]}, {:mint, "~> 1.10", [hex: :mint, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.6", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.17.8 or ~> 0.18.0", [hex: :oxc, repo: "hexpm", optional: false]}, {:varint, "~> 1.6", [hex: :varint, repo: "hexpm", optional: false]}, {:zigler, "~> 0.16.0", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.6", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "f7652948f43484fcc5c0fe007fdcddbd62db595daa16a6f53fb9b3553f66c608"}, "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.10.0", "8f07ff0865cb09a6a0f75e11701e9a46e331e05f737da4e5b01374dcd25b68c0", [:mix], [{:rustler, "~> 0.28", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "c68c9dc732c12acfdf4a7fa06f4e6f48a641179e6be49a474c8a60809929c430"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, "varint": {:hex, :varint, "1.6.0", "7bced828599b2eb84491a9f067f8a5a67fc35a946d3b7582278083c7fd434b00", [:mix], [], "hexpm", "2b4f4a20650aeebe993a70b03bb99571bcbddc27c361322c28b586d8a772ce4e"}, - "vize": {:hex, :vize, "0.14.2", "4baf40c18e00963ce802c03ebb664feaccd8558d7ec62aa43fb07ad8485d0208", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8336744616bc27ce4edc7448cda6f0b8a481cbec9035d3c4d6bf3229be91386d"}, + "vize": {:hex, :vize, "0.15.0", "f13eaecaa6a59c3314e374febc6b21e127560e08836f5d3e8edc4282412d182e", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "e1f2021c1e43d1f7530081dbf00ea93f6e55ea6928d10371a64e1214605316a0"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "zigler_precompiled": {:hex, :zigler_precompiled, "0.1.6", "1c5c889ff30164eb340e599ae987b6c51c3ba0654117ec7a68a6b83d4df39310", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:zigler, "~> 0.13", [hex: :zigler, repo: "hexpm", optional: true]}], "hexpm", "5754d342533b2fb8e767c0ef216ee13c928e17f317cac7d6a01a0972ba68ce4a"}, diff --git a/examples/vue/config/config.exs b/examples/vue/config/config.exs index 71b1590..e27ff43 100644 --- a/examples/vue/config/config.exs +++ b/examples/vue/config/config.exs @@ -37,7 +37,7 @@ config :volt, :format, single_quote: true config :volt, :lint, - plugins: [:typescript, :vue], + plugins: ["typescript", "vue"], tsgolint: System.find_executable("tsgolint"), rules: %{ "correctness" => :deny, diff --git a/guides/cheatsheets/configuration.cheatmd b/guides/cheatsheets/configuration.cheatmd index 00ca691..e25567b 100644 --- a/guides/cheatsheets/configuration.cheatmd +++ b/guides/cheatsheets/configuration.cheatmd @@ -295,7 +295,7 @@ config :volt, :format, arrow_parens: :always ### Plugins ```elixir -config :volt, :lint, plugins: [:typescript, :react] +config :volt, :lint, plugins: ["typescript", "react"] ``` ### Rules diff --git a/guides/features/features.md b/guides/features/features.md index ede89ea..1867953 100644 --- a/guides/features/features.md +++ b/guides/features/features.md @@ -210,7 +210,16 @@ In development, browser `console.log`, `console.warn`, and `console.error` calls ## Error Overlay -Compilation errors in development are displayed as a full-screen browser overlay with the error message. The overlay dismisses on click and clears automatically when the error is fixed. +Compilation errors in development are displayed as a browser overlay with each error's file, line, column, and source frame. It appears on page load as well as during HMR, dismisses on click outside it, and clears automatically when the error is fixed. + +Add [Lumis](https://hex.pm/packages/lumis) and parser packages for your languages to highlight the source frame: + +```elixir +{:lumis, "~> 0.10", only: :dev}, +{:lumis_wasm_typescript, "~> 0.26", only: :dev} +``` + +Without them, or for a language without an installed parser, the frame is plain text. ## Plugins diff --git a/guides/features/formatting-and-linting.md b/guides/features/formatting-and-linting.md index 453caaf..bd63492 100644 --- a/guides/features/formatting-and-linting.md +++ b/guides/features/formatting-and-linting.md @@ -47,7 +47,7 @@ mix volt.lint mix volt.lint --plugin react --plugin typescript ``` -Available plugins: `react`, `typescript`, `unicorn`, `import`, `jsdoc`, `jest`, `vitest`, `jsx_a11y`, `nextjs`, `react_perf`, `promise`, `node`, `vue`, `oxc`. +Plugin, environment, and global names are strings, as in an oxlint config file: `"react"`, `"unicorn"`, `"jsx-a11y"`, and so on. Unknown plugin names are reported as errors. ### Configuration @@ -55,9 +55,9 @@ Available plugins: `react`, `typescript`, `unicorn`, `import`, `jsdoc`, `jest`, config :volt, :lint, sources: ["priv/ts/**/*.ts", "test/javascript/**/*.mjs"], ignore: ["test/javascript/fixtures/**"], - env: [:browser, :node, :mocha], + env: ["browser", "node", "mocha"], globals: %{"AppRuntime" => :readonly}, - plugins: [:typescript], + plugins: ["typescript"], rules: %{ "no-debugger" => :deny, "eqeqeq" => :deny, @@ -75,8 +75,8 @@ Both `mix volt.lint` and `mix volt.js.check` resolve the same per-file settings: config :volt, :lint, root: ".", sources: ["assets/**/*.js", "scripts/**/*.js"], - plugins: [:typescript, :unicorn], - env: [:browser], + plugins: ["typescript", "unicorn"], + env: ["browser"], rules: %{"correctness" => :deny, "unicorn/no-null" => :deny}, overrides: [ %{ @@ -89,7 +89,7 @@ config :volt, :lint, }, %{ files: ["scripts/**/*.js"], - env: %{browser: false, node: true}, + env: %{"browser" => false, "node" => true}, globals: %{"BuildContext" => :readonly} } ] @@ -140,3 +140,5 @@ config :volt, :lint, Volt keeps the Oxlint-style rule shape: configure normal and type-aware rules together under `:rules`. When `--type-aware` is enabled, Volt still runs the normal syntax lint path and also invokes `tsgolint` for supported semantic TypeScript rules. Exits with non-zero status on issues. + +With `--type-aware`, categories also select type-aware rules from the enabled plugins. For example, `"correctness" => :deny` with `plugins: ["typescript"]` runs `typescript/no-floating-promises`. Individual rule settings override their categories, and per-file overrides apply before categories are expanded. Configurations without categories run only the type-aware rules they name. `--type-check` independently enables TypeScript compiler diagnostics. diff --git a/guides/features/frameworks.md b/guides/features/frameworks.md index 5beee9e..7848099 100644 --- a/guides/features/frameworks.md +++ b/guides/features/frameworks.md @@ -15,7 +15,7 @@ config :volt, sources: ["**/*.{js,ts,jsx,tsx}"], import_source: "react" -config :volt, :lint, plugins: [:typescript, :react] +config :volt, :lint, plugins: ["typescript", "react"] ``` ```json @@ -62,7 +62,7 @@ config :volt, ] ] -config :volt, :lint, plugins: [:typescript, :vue] +config :volt, :lint, plugins: ["typescript", "vue"] ``` ```json @@ -115,7 +115,7 @@ config :volt, ] ] -config :volt, :lint, plugins: [:typescript] +config :volt, :lint, plugins: ["typescript"] ``` ```json @@ -155,7 +155,7 @@ config :volt, sources: ["**/*.{js,ts,jsx,tsx}"], plugins: [Volt.Plugin.Solid] -config :volt, :lint, plugins: [:typescript] +config :volt, :lint, plugins: ["typescript"] ``` ```json diff --git a/guides/features/hmr.md b/guides/features/hmr.md index fe5bc66..9f209f5 100644 --- a/guides/features/hmr.md +++ b/guides/features/hmr.md @@ -37,7 +37,7 @@ with the session's state generation. | `.ex`, `.heex`, `.eex` | Incremental Tailwind rebuild, CSS hot-swap | | `.vue` (style-only change) | CSS hot-swap, no page reload | -The browser client auto-reconnects on disconnect and shows compilation errors as an overlay. +The browser client auto-reconnects on disconnect, reloading the page when the connection returns, and shows compilation errors as an overlay. The dev server adds the client to HTML pages the app renders, so the overlay also appears when a page's scripts fail to load. ## Server-side broadcasts @@ -53,10 +53,13 @@ Volt.HMR.full_reload("content/posts/hello.md") # Send a custom update payload, optionally with an HMR boundary Volt.HMR.update("src/counter.ts", [:hmr], boundary: "/assets/counter.ts") -# Show the browser error overlay +# Show the browser error overlay, then hide it once the file is fixed Volt.HMR.error("content/posts/hello.md", "Invalid frontmatter") +Volt.HMR.clear_error("content/posts/hello.md") ``` +Errors can be messages, exceptions, or `OXC.Diagnostic` maps with a location, which the overlay shows with a source frame. They stay current until cleared, so browsers that connect later see them too. + Use this when an external package owns additional dependency graphs, such as pages, layouts, or content collections, while Volt serves the asset graph. `Volt.HMR.invalidate_file/1` evicts Volt's dev compilation state for a source file and marks its module-graph nodes invalidated without broadcasting. Use it before sending your own update when an external package knows a file's compiled output is stale. diff --git a/guides/features/plugins.md b/guides/features/plugins.md index 199a93a..fc4c1b4 100644 --- a/guides/features/plugins.md +++ b/guides/features/plugins.md @@ -305,6 +305,50 @@ iodata containing multiple nodes, or a list of those values. `render!/4` preserv TypeScript for the subsequent Volt build; use `js!/4` when the caller needs compiled browser JavaScript immediately. +### Browser code with npm packages + +Browser code in `priv` can import npm packages that ship with your library. List them +in a `package.json` beside the sources, pinned to exact versions: + +```json +// priv/ts/package.json +{"private": true, "dependencies": {"lit-html": "3.3.3"}} +``` + +Then vendor them: + +```bash +mix volt.priv.vendor priv/ts +``` + +`priv/ts/node_modules` keeps only the files your sources reach: the browser build of +each imported module, its type declarations, and each package's `package.json` and +license. Commit `package.json` and the `npm.lock` that pins the versions, but keep +`node_modules` out of git and vendor before publishing, so the files ship in your Hex +package and nothing is installed at runtime: + +```elixir +# .gitignore: /priv/ts/node_modules/ + +defp aliases do + [ + setup: ["deps.get", "volt.priv.vendor priv/ts"], + # A separate process, so compiling for vendoring leaves Hex's own tasks loaded. + "hex.build": ["cmd mix volt.priv.vendor priv/ts", "hex.build"], + "hex.publish": ["cmd mix volt.priv.vendor priv/ts", "hex.publish"] + ] +end +``` + +Pass `--update` to resolve the versions again. + +`Volt.Priv.bundle!/3` bundles an entry with everything it imports, including the +vendored packages. In a git or path checkout without them, it vendors on first use: + +```elixir +Volt.Priv.bundle!({:my_framework, "ts"}, "widget.ts", format: :esm) +``` + ### Example: AST transform with OXC Use `transform/2` to modify compiled JavaScript. OXC provides `parse/2`, `postwalk/3`, and `patch_string/2` for AST-based transforms: diff --git a/lib/mix/tasks/volt/install.ex b/lib/mix/tasks/volt/install.ex index f643403..64bf414 100644 --- a/lib/mix/tasks/volt/install.ex +++ b/lib/mix/tasks/volt/install.ex @@ -259,7 +259,7 @@ if Code.ensure_loaded?(Igniter) do {:code, Sourceror.parse_string!(""" [ - plugins: [:typescript, :import, :unicorn], + plugins: ["typescript", "import", "unicorn"], rules: %{ "no-debugger" => :deny, "no-unused-vars" => :warn, diff --git a/lib/mix/tasks/volt/js/check.ex b/lib/mix/tasks/volt/js/check.ex index 9556868..04efd22 100644 --- a/lib/mix/tasks/volt/js/check.ex +++ b/lib/mix/tasks/volt/js/check.ex @@ -45,9 +45,7 @@ defmodule Mix.Tasks.Volt.Js.Check do Mix.shell().error("#{length(errors)} file(s) could not be formatted:") Enum.each(errors, fn {file, format_errors} -> - Mix.shell().error( - " #{file}: #{format_errors |> List.wrap() |> Enum.map_join(", ", &Volt.JS.Check.lint_error_message/1)}" - ) + Mix.shell().error(" #{file}: #{Enum.map_join(format_errors, ", ", & &1.message)}") end) false @@ -94,12 +92,13 @@ defmodule Mix.Tasks.Volt.Js.Check do end defp print_lint_diags(diags) do - errors = Enum.count(diags, &(&1.severity == :deny)) - warnings = Enum.count(diags, &(&1.severity == :warn)) + errors = Enum.count(diags, &(&1.severity == :error)) + warnings = Enum.count(diags, &(&1.severity == :warning)) Enum.each(diags, fn diag -> - tag = if diag.severity == :deny, do: "error", else: "warn" - Mix.shell().error(" [#{tag}] #{diag.file}: #{diag.message} (#{diag.rule})") + location = if location = Volt.JS.Check.location(diag), do: "#{location}: ", else: "" + rule = if rule = diag[:rule], do: " (#{rule})", else: "" + Mix.shell().error(" [#{diag.severity}] #{location}#{diag.message}#{rule}") end) Mix.shell().error("#{errors} error(s), #{warnings} warning(s)") diff --git a/lib/mix/tasks/volt/lint.ex b/lib/mix/tasks/volt/lint.ex index 0d9d0f4..91216ae 100644 --- a/lib/mix/tasks/volt/lint.ex +++ b/lib/mix/tasks/volt/lint.ex @@ -11,10 +11,8 @@ defmodule Mix.Tasks.Volt.Lint do ## Options - * `--plugin` — enable an oxlint plugin (repeatable). - Available: `react`, `typescript`, `unicorn`, `import`, `jsdoc`, - `jest`, `vitest`, `jsx_a11y`, `nextjs`, `react_perf`, `promise`, - `node`, `vue`, `oxc` + * `--plugin` — enable an oxlint plugin (repeatable), such as `react`, + `unicorn`, or `jsx-a11y`. Accepts the plugin names of an oxlint config file. * `--fix` — show fix suggestions in output @@ -23,7 +21,7 @@ defmodule Mix.Tasks.Volt.Lint do Configure lint settings in `config :volt, :lint`: config :volt, :lint, - plugins: [:typescript, :react], + plugins: ["typescript", "react"], rules: %{ "no-console" => :warn, "eqeqeq" => :deny, @@ -81,8 +79,8 @@ defmodule Mix.Tasks.Volt.Lint do plugins = case Keyword.get_values(parsed, :plugin) do - [] -> Keyword.get(config, :plugins, [:typescript]) - cli_plugins -> Enum.map(cli_plugins, &String.to_atom/1) + [] -> Keyword.get(config, :plugins, ["typescript"]) + cli_plugins -> cli_plugins end lint_config = Volt.JS.Lint.Config.new(config, Volt.Config.build().root) @@ -106,21 +104,8 @@ defmodule Mix.Tasks.Volt.Lint do options = Keyword.merge(options, plugins: plugins, fix: fix) case OXC.Lint.run(source, file, options) do - {:ok, diags} -> - Enum.map(diags, &Map.put(&1, :file, file)) - - {:error, parse_errors} -> - Enum.map(parse_errors, fn msg -> - %{ - file: file, - rule: "parse-error", - message: msg, - severity: :deny, - span: {0, 0}, - labels: [], - help: nil - } - end) + {:ok, diags} -> diags + {:error, parse_errors} -> parse_errors end end) end @@ -171,8 +156,6 @@ defmodule Mix.Tasks.Volt.Lint do defp print_diagnostic(diag, edge_color) do tag = severity_tag(diag.severity) arrow = priority_arrow(diag.severity) - source = File.read!(diag.file) - {line, col} = offset_to_line_col(source, elem(diag.span, 0)) Mix.shell().info( IO.ANSI.format([ @@ -196,20 +179,17 @@ defmodule Mix.Tasks.Volt.Lint do "┃ ", :reset, :faint, - diag.file, + Volt.JS.Check.location(diag) || "", :default_color, :faint, - ":#{line}:#{col}", - " #(", - diag.rule, - ")" + if(rule = diag[:rule], do: " #(#{rule})", else: "") ]) ) end defp print_summary(results, files) do - errors = Enum.count(results, &(&1.severity == :deny)) - warnings = Enum.count(results, &(&1.severity == :warn)) + errors = Enum.count(results, &(&1.severity == :error)) + warnings = Enum.count(results, &(&1.severity == :warning)) by_category = results @@ -260,25 +240,15 @@ defmodule Mix.Tasks.Volt.Lint do end) end - defp severity_tag(:deny), do: "[E]" - defp severity_tag(:warn), do: "[W]" - defp severity_tag(_), do: "[I]" + defp severity_tag(:error), do: "[E]" + defp severity_tag(:warning), do: "[W]" - defp priority_arrow(:deny), do: "↗" - defp priority_arrow(:warn), do: "→" - defp priority_arrow(_), do: "→" + defp priority_arrow(:error), do: "↗" + defp priority_arrow(:warning), do: "→" defp pl(1), do: "" defp pl(_), do: "s" - defp offset_to_line_col(source, offset) do - prefix = binary_part(source, 0, min(offset, byte_size(source))) - lines = String.split(prefix, "\n") - line = length(lines) - col = lines |> List.last() |> String.length() |> Kernel.+(1) - {line, col} - end - defp term_columns do case :io.columns() do {:ok, cols} -> cols diff --git a/lib/mix/tasks/volt/priv/vendor.ex b/lib/mix/tasks/volt/priv/vendor.ex new file mode 100644 index 0000000..291e4ea --- /dev/null +++ b/lib/mix/tasks/volt/priv/vendor.ex @@ -0,0 +1,34 @@ +defmodule Mix.Tasks.Volt.Priv.Vendor do + use Mix.Task + + @shortdoc "Vendor npm packages for browser code under priv" + + @moduledoc """ + Vendors the npm packages that `DIR/package.json` lists into `DIR/node_modules`, + keeping only the files the sources in `DIR` reach. See `Volt.Priv.Vendor`. + + mix volt.priv.vendor priv/ts + mix volt.priv.vendor priv/ts --update + + Run it before `mix hex.publish`, so the package ships the vendored files. + + ## Options + + * `--update` — resolve the versions again instead of using `DIR/npm.lock` + """ + + @impl true + def run(args) do + {opts, dirs} = OptionParser.parse!(args, strict: [update: :boolean]) + + if dirs == [], do: Mix.raise("Expected a directory, such as: mix volt.priv.vendor priv/ts") + + Mix.Task.run("app.config") + Application.ensure_all_started(:req) + + for dir <- dirs do + files = Volt.Priv.Vendor.run!(dir, update: opts[:update] == true) + Mix.shell().info("Vendored #{length(files)} files into #{Path.join(dir, "node_modules")}") + end + end +end diff --git a/lib/volt/application.ex b/lib/volt/application.ex index 10c810b..c613cc5 100644 --- a/lib/volt/application.ex +++ b/lib/volt/application.ex @@ -10,6 +10,7 @@ defmodule Volt.Application do Volt.HMR.GlobGraph.create_table() Volt.HMR.StyleGraph.create_table() Volt.HMR.ModuleGraph.create_table() + Volt.HMR.Errors.create_table() children = [ {Registry, keys: :duplicate, name: Volt.HMR.Registry}, diff --git a/lib/volt/builder/rewriter.ex b/lib/volt/builder/rewriter.ex index d48f6fa..b856944 100644 --- a/lib/volt/builder/rewriter.ex +++ b/lib/volt/builder/rewriter.ex @@ -146,15 +146,7 @@ defmodule Volt.Builder.Rewriter do patches end - defp preload_helper do - {:volt, "ts"} - |> Volt.Priv.path("client/preload-global.ts") - |> Volt.JS.Runtime.Bundler.bundle_file() - |> case do - {:ok, code} -> code <> "\n" - {:error, reason} -> raise "could not bundle Volt preload helper: #{inspect(reason)}" - end - end + defp preload_helper, do: Volt.Priv.bundle!({:volt, "ts"}, "client/preload-global.ts") <> "\n" defp worker_filename_map(worker_specs, ctx) do worker_specs diff --git a/lib/volt/dev/cleanup.ex b/lib/volt/dev/cleanup.ex index 1613654..ac813c3 100644 --- a/lib/volt/dev/cleanup.ex +++ b/lib/volt/dev/cleanup.ex @@ -9,6 +9,7 @@ defmodule Volt.Dev.Cleanup do Volt.HMR.GlobGraph.clear_session(session) Volt.HMR.StyleGraph.clear_session(session) Volt.HMR.ModuleGraph.clear_session(session) + Volt.HMR.Errors.clear_session(session) :ok end end diff --git a/lib/volt/dev/console_forwarder.ex b/lib/volt/dev/console_forwarder.ex index 81fe6b0..b60b08f 100644 --- a/lib/volt/dev/console_forwarder.ex +++ b/lib/volt/dev/console_forwarder.ex @@ -9,7 +9,7 @@ defmodule Volt.Dev.ConsoleForwarder do defmodule Payload do @moduledoc false - use JSONCodec, fast_path: :json + use JSONCodec defstruct [:level, :source, :args] diff --git a/lib/volt/dev/error.ex b/lib/volt/dev/error.ex new file mode 100644 index 0000000..f61e90a --- /dev/null +++ b/lib/volt/dev/error.ex @@ -0,0 +1,176 @@ +defmodule Volt.Dev.Error do + @moduledoc """ + JSON-safe error entries for the development error overlay. + + Build errors arrive as `OXC.Diagnostic` maps, compiler messages, exceptions, or + arbitrary terms. `entries/2` turns any of them into maps the browser overlay + renders, with a source frame when the file and line are known. + + With the optional `:lumis` dependency and a Lumis parser package for the file's + language, such as `:lumis_wasm_typescript`, entries also carry `frame_html`, the + frame with syntax highlighting. + """ + + @theme "github_dark" + + @type entry :: %{ + title: String.t(), + message: String.t(), + file: String.t() | nil, + line: pos_integer() | nil, + column: pos_integer() | nil, + hint: String.t() | nil, + frame: String.t() | nil, + frame_html: String.t() | nil, + stack: String.t() | nil + } + + @doc """ + Build overlay entries from an error `reason`. + + ## Options + + * `:file` — the source path the errors belong to. It names entries that have + no file or only its basename, and is read for source frames. + * `:title` — the overlay heading for these errors. Default: `"Build error"` + """ + @spec entries(term(), keyword()) :: [entry()] + def entries(reason, opts \\ []) + + def entries(errors, opts) when is_list(errors), do: Enum.flat_map(errors, &entries(&1, opts)) + + def entries(%{__exception__: true} = exception, opts), + do: [entry(Exception.message(exception), opts, [])] + + def entries(%{message: message} = diagnostic, opts) when is_binary(message) do + {line, column} = position(diagnostic[:position]) + hint = if is_binary(diagnostic[:details]), do: diagnostic[:details] + + stack = + case diagnostic[:stacktrace] do + [_ | _] = stacktrace -> Exception.format_stacktrace(stacktrace) + _none -> nil + end + + [ + entry(message, opts, + file: diagnostic[:file], + line: line, + column: column, + hint: hint, + stack: stack + ) + ] + end + + def entries(message, opts) when is_binary(message), do: [entry(message, opts, [])] + def entries(reason, opts), do: [entry(inspect(reason), opts, [])] + + defp entry(message, opts, fields) do + file = source_file(fields[:file], opts[:file]) + line = fields[:line] + column = fields[:column] + {frame, frame_html} = frames(file, line, column) + + %{ + title: opts[:title] || "Build error", + message: message, + file: file && Path.relative_to_cwd(file), + line: line, + column: column, + hint: fields[:hint], + frame: frame, + frame_html: frame_html, + stack: fields[:stack] + } + end + + defp source_file(nil, path), do: path + defp source_file(file, nil), do: file + + defp source_file(file, path) do + if file == Path.basename(path), do: path, else: file + end + + defp position({line, column}), do: {line, column} + defp position(line) when is_integer(line) and line > 0, do: {line, nil} + defp position(_position), do: {nil, nil} + + defp frames(file, line, column) when is_binary(file) and is_integer(line) do + with {:ok, source} <- File.read(file), + lines = String.split(source, ~r/\r?\n/), + true <- line <= length(lines) do + first = max(line - 2, 1) + last = min(line + 1, length(lines)) + width = last |> Integer.to_string() |> byte_size() + frame = %{first: first, line: line, column: column, width: width} + texts = Enum.slice(lines, (first - 1)..(last - 1)//1) + + html = + with [_ | _] = highlighted <- highlight(file, source, first..last//1) do + # Lumis leaves out a trailing empty line. + highlighted = highlighted ++ List.duplicate("", length(texts) - length(highlighted)) + render(frame, highlighted, &html_gutter/1) + end + + {render(frame, texts, & &1), html} + else + _ -> {nil, nil} + end + end + + defp frames(_file, _line, _column), do: {nil, nil} + + defp render(frame, texts, gutter) do + texts + |> Enum.with_index(frame.first) + |> Enum.flat_map(fn {text, number} -> + marker = if number == frame.line, do: ">", else: " " + padded = number |> Integer.to_string() |> String.pad_leading(frame.width) + row = gutter.("#{marker} #{padded} | ") <> text + + if number == frame.line and frame.column do + blank = String.duplicate(" ", frame.width) + [row, gutter.(" #{blank} | #{String.duplicate(" ", frame.column - 1)}^")] + else + [row] + end + end) + |> Enum.join("\n") + end + + # The error line's marker and caret stand out; other gutters are dim. + defp html_gutter(gutter) do + color = if String.contains?(gutter, [">", "^"]), do: "#ff6b9a", else: "#6b5b8f" + ~s(#{String.replace(gutter, ">", ">")}) + end + + if Code.ensure_loaded?(Lumis) do + # Highlights the whole file, so tokens that span lines keep their colors, and + # keeps the lines in `range`. Nil when no Lumis parser covers the language. + defp highlight(file, source, range) do + language = Lumis.Languages.guess(file, source) + formatter = {:html_inline, language: language, theme: @theme} + + with true <- language != "plaintext", + :ok <- Lumis.Languages.load(language), + {:ok, html} <- Lumis.highlight(source, formatter: formatter) do + html + |> String.split("\n") + |> Enum.slice((range.first - 1)..(range.last - 1)//1) + |> Enum.map(&line_html/1) + else + _ -> nil + end + end + + defp line_html(line) do + case Regex.run(~r{]*>(.*)}, line, capture: :all_but_first) do + [html] -> html + nil -> "" + end + end + else + defp highlight(_file, _source, _range), do: nil + end +end diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index a9326fe..d327d91 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -335,7 +335,7 @@ defmodule Volt.DevServer do serve(conn, relative, config) :no_match -> - conn + Volt.DevServer.ClientTag.register(conn) end end end @@ -359,6 +359,7 @@ defmodule Volt.DevServer do defp compile_and_serve_virtual(conn, id, source, content_type, config) do case Volt.Pipeline.compile(id, source, pipeline_opts(config, id)) do {:ok, result} -> + Volt.HMR.clear_error(id, session: config.session) content_type = content_type || Volt.MIME.javascript() mod_url = virtual_url(id) code = code_for_request(result, mod_url, content_type, false) @@ -376,10 +377,7 @@ defmodule Volt.DevServer do send_compiled(conn, code, result.sourcemap, content_type) {:error, errors} -> - conn - |> Conn.put_resp_content_type(Volt.MIME.javascript()) - |> Conn.send_resp(500, error_overlay(errors)) - |> Conn.halt() + send_compile_error(conn, id, errors, config) end end @@ -476,6 +474,7 @@ defmodule Volt.DevServer do case Volt.Pipeline.compile(module_id, source, pipeline_opts(config, module_id)) do {:ok, result} -> + Volt.HMR.clear_error(file_path, session: config.session) Volt.HMR.GlobGraph.update_from_source(file_path, source, config.tables || config.session) Volt.HMR.ImportGraph.update_from_compiled( @@ -518,10 +517,7 @@ defmodule Volt.DevServer do send_compiled(conn, code, result.sourcemap, content_type) {:error, errors} -> - conn - |> Conn.put_resp_content_type(Volt.MIME.javascript()) - |> Conn.send_resp(500, error_overlay(errors)) - |> Conn.halt() + send_compile_error(conn, file_path, errors, config) end end @@ -943,41 +939,25 @@ defmodule Volt.DevServer do if Volt.MIME.javascript?(content_type), do: Volt.Dev.ConsoleForwarder.inject(code), else: code end - defp error_overlay(errors) do - msg = - errors - |> List.wrap() - |> Enum.map_join("\n", fn - %{message: m} -> m - e when is_binary(e) -> e - e -> inspect(e) - end) - - overlay = support_module!("client/overlay.ts") - overlay <> "\n" <> error_overlay_invocation(msg) - end + # The HMR client shows the errors: a failed module fails its importers before + # any script in this response could run. + defp send_compile_error(conn, path, errors, config) do + Volt.HMR.error(path, errors, session: config.session) + message = "[Volt] Could not compile #{Path.relative_to_cwd(path)}" - defp error_overlay_invocation(message) do - "renderErrorOverlay($message, $options)" - |> OXC.parse!("volt-error-overlay-call.ts") - |> OXC.bind(message: {:literal, message}, options: {:literal, %{title: "Compilation error"}}) - |> OXC.codegen!() + conn + |> Conn.put_resp_content_type(Volt.MIME.javascript()) + |> Conn.send_resp(500, "throw new Error(#{Jason.encode!(message)})") + |> Conn.halt() end defp client_module!(heartbeat_interval) do - entry = Volt.Priv.path(@support_modules, "client/hmr.ts") - - case Volt.JS.Runtime.Bundler.bundle_file(entry, - format: :esm, - define: %{"__VOLT_HEARTBEAT__" => Integer.to_string(heartbeat_interval)} - ) do - {:ok, code} when is_binary(code) -> code - {:error, reason} -> raise "Could not bundle Volt dev client: #{inspect(reason)}" - end + Volt.Priv.bundle!(@support_modules, "client/hmr.ts", + format: :esm, + define: %{"__VOLT_HEARTBEAT__" => Integer.to_string(heartbeat_interval)} + ) end - defp support_module!(relative), do: Volt.Priv.js!(@support_modules, relative) - defp support_module!(relative, bindings) do Volt.Priv.js!(@support_modules, relative, bindings, rewrite_specifiers: @runtime_rewrites) end diff --git a/lib/volt/dev_server/client_tag.ex b/lib/volt/dev_server/client_tag.ex new file mode 100644 index 0000000..9e3f3eb --- /dev/null +++ b/lib/volt/dev_server/client_tag.ex @@ -0,0 +1,45 @@ +defmodule Volt.DevServer.ClientTag do + @moduledoc false + # Loads the HMR client as its own script in HTML pages, as Vite does. Modules + # also import the client, but a module that fails to compile stops its whole + # graph from running, and the client must still show the error overlay. + + alias Plug.Conn + + @client "/@volt/client.js" + @tag ~s() + + @spec register(Conn.t()) :: Conn.t() + def register(conn), do: Conn.register_before_send(conn, &inject/1) + + defp inject(%Conn{resp_body: body} = conn) when not is_nil(body) do + if html?(conn) do + html = IO.iodata_to_binary(body) + + if String.contains?(html, @client), + do: conn, + else: %{conn | resp_body: insert(html)} + else + conn + end + end + + defp inject(conn), do: conn + + defp html?(conn) do + conn + |> Conn.get_resp_header("content-type") + |> Enum.any?(&String.starts_with?(&1, "text/html")) + end + + defp insert(html) do + case :binary.match(html, "") do + {position, _length} -> + binary_part(html, 0, position) <> + @tag <> binary_part(html, position, byte_size(html) - position) + + :nomatch -> + html + end + end +end diff --git a/lib/volt/hmr.ex b/lib/volt/hmr.ex index 5f603d6..c1236f1 100644 --- a/lib/volt/hmr.ex +++ b/lib/volt/hmr.ex @@ -44,9 +44,37 @@ defmodule Volt.HMR do @spec style_update(String.t()) :: :ok def style_update(path, opts \\ []), do: update(path, [:style], opts) - @doc "Broadcast an error payload for a source path." - @spec error(String.t(), term()) :: :ok - def error(path, reason, opts \\ []), do: broadcast(:error, %{path: path, reason: reason}, opts) + @doc """ + Report the errors for a source path and show them in connected browsers. + + `reason` may be `OXC.Diagnostic` maps, messages, exceptions, or any other term; + see `Volt.Dev.Error.entries/2`. The errors stay current until `clear_error/2`, + so browsers that connect later show them too. + + ## Options + + * `:session` — the development session. Default: `:default` + * `:title` — the overlay heading for these errors, such as `"Render error"`. + Default: `"Build error"` + """ + @spec error(String.t(), term(), keyword()) :: :ok + def error(path, reason, opts \\ []) do + session = Keyword.get(opts, :session, :default) + entries = Volt.Dev.Error.entries(reason, file: path, title: Keyword.get(opts, :title)) + Volt.HMR.Errors.put(session, path, entries) + broadcast_errors(session) + end + + @doc "Clear the errors reported for a source path, hiding the overlay when none remain." + @spec clear_error(String.t(), keyword()) :: :ok + def clear_error(path, opts \\ []) do + session = Keyword.get(opts, :session, :default) + if Volt.HMR.Errors.delete(session, path), do: broadcast_errors(session), else: :ok + end + + defp broadcast_errors(session) do + broadcast(:error, %{errors: Volt.HMR.Errors.list(session)}, session: session) + end @doc "Invalidate Volt's dev compilation state for a source file without broadcasting." @spec invalidate_file(String.t()) :: :ok diff --git a/lib/volt/hmr/errors.ex b/lib/volt/hmr/errors.ex new file mode 100644 index 0000000..e15c547 --- /dev/null +++ b/lib/volt/hmr/errors.ex @@ -0,0 +1,23 @@ +defmodule Volt.HMR.Errors do + @moduledoc false + # Current development errors per session and source path, so browsers that + # connect after a failure still show the overlay. + + @table :volt_hmr_errors + + def create_table, do: Volt.ETS.create_named_set(@table) + + def put(session, path, entries), do: Volt.ETS.put(@table, {{session, path}, entries}) + + @doc "Remove the errors for a path and return whether there were any." + def delete(session, path), do: :ets.take(@table, {session, path}) != [] + + def list(session) do + @table + |> :ets.match_object({{session, :_}, :_}) + |> Enum.sort() + |> Enum.flat_map(fn {_key, entries} -> entries end) + end + + def clear_session(session), do: Volt.ETS.clear_session(@table, session) +end diff --git a/lib/volt/hmr/message.ex b/lib/volt/hmr/message.ex index b2aecc7..d46557d 100644 --- a/lib/volt/hmr/message.ex +++ b/lib/volt/hmr/message.ex @@ -8,7 +8,8 @@ defmodule Volt.HMR.Message do ## Wire types * `update` — an HMR update payload (`path`, `changes`, optional `boundary`, `timestamp`) - * `error` — a build/runtime error to surface in the overlay + * `error` — the current build errors (`errors`, as `t:Volt.Dev.Error.entry/0` maps); + an empty list hides the overlay * `ping` — heartbeat sent by the browser client * `pong` — heartbeat reply from the server diff --git a/lib/volt/hmr/socket.ex b/lib/volt/hmr/socket.ex index 65a9c95..dda6748 100644 --- a/lib/volt/hmr/socket.ex +++ b/lib/volt/hmr/socket.ex @@ -14,7 +14,11 @@ defmodule Volt.HMR.Socket do def init(args) do session = Keyword.get(args, :session, :default) Registry.register(Volt.HMR.Registry, Volt.HMR.Channel.key(session), nil) - {:ok, %{}} + + case Volt.HMR.Errors.list(session) do + [] -> {:ok, %{}} + errors -> push(%Volt.HMR.Message{type: :error, payload: %{errors: errors}}, %{}) + end end # Heartbeat: the browser client sends a `{"type":"ping"}` JSON message diff --git a/lib/volt/js/check.ex b/lib/volt/js/check.ex index 696ee8f..88b6631 100644 --- a/lib/volt/js/check.ex +++ b/lib/volt/js/check.ex @@ -39,36 +39,38 @@ defmodule Volt.JS.Check do def promote_type_check_diagnostic(%{rule: rule} = diagnostic, opts) do if Keyword.get(opts, :type_check, false) and type_check_diagnostic?(rule) do - %{diagnostic | severity: :deny} + %{diagnostic | severity: :error} else diagnostic end end + @doc "Formats a diagnostic's location as `file:line:column`, `file`, or `nil` without a file." + @spec location(OXC.Diagnostic.t()) :: String.t() | nil + def location(%{file: nil}), do: nil + def location(%{file: file, position: {line, column}}), do: "#{file}:#{line}:#{column}" + def location(%{file: file}), do: file + def type_check_diagnostic?(rule) do rule |> to_string() |> String.match?(~r/^(typescript\/)?TS\d+$/) end - def lint_error_message(%{message: message}), do: message - def lint_error_message(message) when is_binary(message), do: message - def lint_error_message(message), do: inspect(message) - defp ast_lint(files, config) do Enum.flat_map(files, fn file -> source = File.read!(file) options = Volt.JS.Lint.Config.options(config, file) case OXC.Lint.run(source, file, options) do - {:ok, diagnostics} -> Enum.map(diagnostics, &Map.put(&1, :file, file)) - {:error, errors} -> Enum.map(errors, &lint_error(&1, file)) + {:ok, diagnostics} -> diagnostics + {:error, errors} -> errors end end) end defp type_aware_lint(files, config, lint_config, opts) do - {files, source_overrides, source_files} = type_aware_inputs(files, config) + {files, source_overrides, source_files} = type_aware_inputs(files) common_opts = [ @@ -80,28 +82,28 @@ defmodule Volt.JS.Check do files |> Enum.group_by(fn file -> original = Map.get(source_files, Path.expand(file), file) - - lint_config - |> Volt.JS.Lint.Config.options(original) - |> Keyword.fetch!(:rules) - |> typescript_rules() + lint_config |> Volt.JS.Lint.Config.options(original) |> OXC.Lint.type_aware_rules() end) |> Enum.sort_by(fn {rules, _files} -> rules end) - |> Enum.flat_map(fn {rules, batch} -> - case run_type_aware_lint(batch, Keyword.put(common_opts, :rules, rules)) do - {:ok, diagnostics} -> - Enum.map(diagnostics, fn diagnostic -> - diagnostic |> restore_sfc_file(source_files) |> promote_type_check_diagnostic(opts) - end) - - {:error, errors} -> - Enum.map(errors, &lint_error/1) - end + |> Enum.flat_map(fn + {{:ok, rules}, batch} -> + case run_type_aware_lint(batch, Keyword.put(common_opts, :rules, rules)) do + {:ok, diagnostics} -> + Enum.map(diagnostics, fn diagnostic -> + diagnostic |> restore_sfc_file(source_files) |> promote_type_check_diagnostic(opts) + end) + + {:error, errors} -> + errors + end + + {{:error, errors}, _batch} -> + errors end) end - defp type_aware_inputs(files, config) do - plugins = Keyword.get(config, :plugins, []) + defp type_aware_inputs(files) do + plugins = Volt.Config.build().plugins Enum.reduce(files, {[], %{}, %{}}, fn file, {files, overrides, source_files} -> if type_aware_file?(file) do @@ -170,28 +172,16 @@ defmodule Volt.JS.Check do defp unknown_tsgolint_rule(errors) do Enum.find_value(errors, fn error -> - case Regex.run(~r/unknown rule: ([\w-]+)/, lint_error_message(error)) do + case Regex.run(~r/unknown rule: ([\w-]+)/, error.message) do [_, rule] -> rule _ -> nil end end) end - defp typescript_rules(rules) do - Map.filter(rules, fn {rule, _config} -> - String.starts_with?(to_string(rule), "typescript/") - end) - end - defp type_aware_options(config) do config |> Keyword.take([:tsgolint, :fix, :fix_suggestions, :cwd]) |> Enum.reject(fn {_key, value} -> is_nil(value) end) end - - defp lint_error(message), do: lint_error(message, "volt.js.check") - - defp lint_error(message, file) do - %{severity: :deny, file: file, message: lint_error_message(message), rule: "oxc/lint"} - end end diff --git a/lib/volt/js/lint/config.ex b/lib/volt/js/lint/config.ex index e19e931..f28c4b8 100644 --- a/lib/volt/js/lint/config.ex +++ b/lib/volt/js/lint/config.ex @@ -2,6 +2,9 @@ defmodule Volt.JS.Lint.Config do @moduledoc """ Resolves lint options for a source file without reading the filesystem. + Plugin, rule, environment, and global names are strings, as in an oxlint + config file. + Override globs are relative to the lint discovery root. Every matching override is applied in declaration order; later entries replace individual rules, environments and globals, not their entire maps. Environment lists enable @@ -21,10 +24,12 @@ defmodule Volt.JS.Lint.Config do @spec new(keyword(), String.t()) :: t() def new(config, root) do options = - [plugins: [:typescript], custom_rules: [], fix: false] + [plugins: ["typescript"], custom_rules: [], fix: false] |> Keyword.merge(Keyword.take(config, [:plugins, :custom_rules, :fix])) |> Keyword.merge(maps(config)) + names!(options[:plugins], :plugins) + overrides = Enum.map(Keyword.get(config, :overrides, []), fn override -> override = Map.new(override) @@ -62,12 +67,18 @@ defmodule Volt.JS.Lint.Config do if Path.type(relative) == :relative and List.first(Path.split(relative)) != ".." do relative = relative |> Path.split() |> Enum.join("/") - Enum.reduce(config.overrides, config.options, &apply_override(&1, relative, &2)) + config.overrides + |> Enum.reduce(config.options, &apply_override(&1, relative, &2)) + |> enabled_environments() else - config.options + enabled_environments(config.options) end end + defp enabled_environments(options) do + Keyword.update!(options, :env, fn env -> for {name, true} <- env, do: name end) + end + defp apply_override({globs, options}, relative, inherited) do if Enum.any?(globs, &GlobEx.match?(&1, relative)) do Keyword.merge(inherited, options, fn _key, base, override -> Map.merge(base, override) end) @@ -78,16 +89,27 @@ defmodule Volt.JS.Lint.Config do defp maps(config) do [ - rules: names(Keyword.get(config, :rules, %{})), - globals: names(Keyword.get(config, :globals, %{})), - env: environments(Keyword.get(config, :env, [])) + rules: named!(Keyword.get(config, :rules, %{}), :rules), + globals: named!(Keyword.get(config, :globals, %{}), :globals), + env: environments!(Keyword.get(config, :env, [])) ] end - defp names(map), do: Map.new(map, fn {name, value} -> {to_string(name), value} end) + defp environments!(env) when is_list(env), do: env |> names!(:env) |> Map.new(&{&1, true}) + defp environments!(env) when is_map(env), do: named!(env, :env) - defp environments(env) when is_list(env), do: Map.new(env, &{to_string(&1), true}) - defp environments(env) when is_map(env), do: names(env) + defp named!(map, key) when is_map(map) do + names!(Map.keys(map), key) + map + end + + defp names!(names, key) do + unless is_list(names) and Enum.all?(names, &is_binary/1) do + raise ArgumentError, "lint #{inspect(key)} names must be strings, got: #{inspect(names)}" + end + + names + end defp compile_glob!(pattern) when is_binary(pattern) do if Path.type(pattern) != :relative or ".." in Path.split(pattern) do diff --git a/lib/volt/js/transforms/imports.ex b/lib/volt/js/transforms/imports.ex index 9ac36ce..1538666 100644 --- a/lib/volt/js/transforms/imports.ex +++ b/lib/volt/js/transforms/imports.ex @@ -40,10 +40,7 @@ defmodule Volt.JS.Transforms.Imports do @spec rewrite!(String.t(), String.t(), (String.t() -> {:rewrite, String.t()} | :keep)) :: String.t() def rewrite!(source, filename, rewrite_fn) do - case rewrite(source, filename, rewrite_fn) do - {:ok, result} -> result - {:error, errors} -> raise "Import rewrite error: #{inspect(errors)}" - end + source |> rewrite(filename, rewrite_fn) |> OXC.Error.unwrap!() end @doc """ diff --git a/lib/volt/priv.ex b/lib/volt/priv.ex index 3e8e699..c1710bd 100644 --- a/lib/volt/priv.ex +++ b/lib/volt/priv.ex @@ -23,8 +23,17 @@ defmodule Volt.Priv do Volt build. `js!/3` and `js!/4` additionally compile the rendered source to browser JavaScript. This keeps templates valid TypeScript or JavaScript files instead of requiring EEx or textual source replacement. + + `bundle!/3` bundles an entry with everything it imports into one module. npm + packages it imports are vendored beside the sources with `mix volt.priv.vendor` + (see `Volt.Priv.Vendor`), so they ship in the package and no install happens at + runtime: + + Volt.Priv.bundle!(assets, "widget.ts", format: :esm) """ + require Logger + @type source :: atom() | {atom(), String.t()} @type bindings :: keyword() | map() @@ -66,6 +75,49 @@ defmodule Volt.Priv do |> rewrite_specifiers(opts[:rewrite_specifiers], relative) end + @doc """ + Bundles an entry under `priv` with the modules it imports, including npm packages + vendored beside it with `mix volt.priv.vendor`. + + Options are passed to `OXC.bundle/2`, such as `:format` and `:define`. + """ + @spec bundle(source(), String.t(), keyword()) :: {:ok, String.t()} | {:error, term()} + def bundle(source, relative, opts \\ []) when is_binary(relative) do + entry = path(source, relative) + entry |> vendor_root() |> ensure_vendored() + Volt.JS.Runtime.Bundler.bundle_file(entry, opts) + end + + # The nearest directory above the entry with a vendoring `package.json`. + defp vendor_root(entry) do + entry + |> Path.dirname() + |> Stream.iterate(&Path.dirname/1) + |> Enum.take_while(&(Path.basename(&1) != "priv")) + |> Enum.find(&File.regular?(Path.join(&1, "package.json"))) + end + + # Git and path checkouts have no vendored files, so vendor them on first use. + defp ensure_vendored(nil), do: :ok + + defp ensure_vendored(dir) do + unless File.dir?(Path.join(dir, "node_modules")) do + Logger.info("[Volt] Vendoring npm packages for #{dir}") + Volt.Priv.Vendor.run!(dir) + end + + :ok + end + + @doc "Like `bundle/3`, but raises when the entry cannot be bundled." + @spec bundle!(source(), String.t(), keyword()) :: String.t() + def bundle!(source, relative, opts \\ []) do + case bundle(source, relative, opts) do + {:ok, code} -> code + {:error, reason} -> raise "could not bundle #{relative}: #{inspect(reason)}" + end + end + @doc """ Emits browser JavaScript for a support file stored under `priv`. diff --git a/lib/volt/priv/vendor.ex b/lib/volt/priv/vendor.ex new file mode 100644 index 0000000..a8fc7d4 --- /dev/null +++ b/lib/volt/priv/vendor.ex @@ -0,0 +1,225 @@ +defmodule Volt.Priv.Vendor do + @moduledoc """ + Vendors npm packages for browser code kept under `priv`. + + List the packages in a `package.json` beside the sources, pinned to exact + versions, and commit it with the `npm.lock` that vendoring writes: + + ```json + {"private": true, "dependencies": {"lit-html": "3.3.3"}} + ``` + + `mix volt.priv.vendor priv/ts` installs the locked versions and writes only the + files the sources reach to `node_modules` beside them: the browser build of every + imported module, the type declarations behind it, and each package's + `package.json` and license. Keep that directory out of git and vendor before + publishing, so the files ship in the Hex package and nothing is installed at + runtime. + + `Volt.Priv.bundle!/3` resolves imports such as `import { html } from 'lit-html'` + from there, and vendors on first use when the directory is missing, as in a git + checkout. + """ + + alias NPM.Resolution.PackageResolver + + @source_extensions ~w(.ts .tsx .mts .js .jsx .mjs) + @type_conditions ["types", "import", "default"] + + @doc """ + Vendors the packages that `dir/package.json` lists into `dir/node_modules`. + + ## Options + + * `:update` — resolve the versions again instead of using `dir/npm.lock` + """ + @spec run!(Path.t(), keyword()) :: [Path.t()] + def run!(dir, opts \\ []) do + with_install(dir, opts, fn install_dir, files -> + node_modules = Path.join(dir, "node_modules") + File.rm_rf!(node_modules) + + for file <- files do + target = Path.join(node_modules, file) + File.mkdir_p!(Path.dirname(target)) + File.cp!(Path.join([install_dir, "node_modules", file]), target) + end + + File.cp!(Path.join(install_dir, "npm.lock"), Path.join(dir, "npm.lock")) + files + end) + end + + defp with_install(dir, opts, fun) do + packages = dir |> Path.join("package.json") |> File.read!() |> Jason.decode!() + packages = Map.get(packages, "dependencies", %{}) + lockfile = Path.join(dir, "npm.lock") + + install_dir = + Path.join(System.tmp_dir!(), "volt-vendor-#{System.unique_integer([:positive])}") + + try do + Volt.JS.Runtime.Installer.install!(packages, + install_dir: install_dir, + lockfile: + if(File.regular?(lockfile) and not Keyword.get(opts, :update, false), do: lockfile) + ) + + fun.(install_dir, reachable(dir, packages, Path.join(install_dir, "node_modules"))) + after + File.rm_rf!(install_dir) + end + end + + @doc false + # The files the sources in `dir` reach in `node_modules`, relative to it. + @spec reachable(Path.t(), map(), Path.t()) :: [Path.t()] + def reachable(dir, packages, node_modules) do + roots = + for source <- sources(dir), + specifier <- imports(source), + Map.has_key?(packages, package_name(specifier)), + kind <- [:js, :types], + do: {kind, specifier, node_modules} + + roots + |> walk(MapSet.new()) + |> Enum.flat_map(&[&1 | package_files(&1, node_modules)]) + |> Enum.uniq() + |> Enum.map(&Path.relative_to(&1, node_modules)) + |> Enum.sort() + end + + defp walk([], seen), do: seen + + defp walk([{kind, specifier, from} | rest], seen) do + case resolve(kind, specifier, from) do + {:ok, path} -> + if MapSet.member?(seen, path) do + walk(rest, seen) + else + next = for import <- imports(path), do: {kind, import, Path.dirname(path)} + walk(next ++ rest, MapSet.put(seen, path)) + end + + :error -> + walk(rest, seen) + end + end + + defp resolve(:js, specifier, from) do + specifier + |> PackageResolver.resolve(from, + conditions: Volt.JS.Resolution.browser_conditions(), + extensions: Volt.JS.Extensions.node_resolvable() + ) + |> file_result() + end + + # TypeScript's own lookup: a `types` export or a declaration beside the module, + # the package's `types` field, then the same path under `@types`. + defp resolve(:types, specifier, from) do + with :error <- declaration(specifier, from), + :error <- package_types(specifier, from), + false <- relative?(specifier) or String.starts_with?(specifier, "@types/") do + resolve(:types, types_specifier(specifier), from) + else + true -> :error + found -> found + end + end + + # Declarations drop the module extension: `lib/index.js` is typed by `lib/index.d.ts`. + defp declaration(specifier, from) do + Enum.find_value([specifier, Path.rootname(specifier, Path.extname(specifier))], :error, fn + specifier -> + with {:ok, path} <- + specifier + |> PackageResolver.resolve(from, + conditions: @type_conditions, + extensions: [".d.ts"] + ) + |> file_result(), + {:ok, declaration} <- declaration_for(path) do + {:ok, declaration} + else + _ -> nil + end + end) + end + + defp file_result({:ok, path}), do: {:ok, path} + defp file_result(_other), do: :error + + defp declaration_for(path) do + declaration = + if String.ends_with?(path, ".d.ts"), + do: path, + else: Path.rootname(path) <> ".d.ts" + + if File.regular?(declaration), do: {:ok, declaration}, else: :error + end + + # A package's `types` field describes its root only. + defp package_types(specifier, from) do + with true <- not relative?(specifier) and package_name(specifier) == specifier, + {:ok, package_dir} <- package_dir(specifier, from), + {:ok, json} <- File.read(Path.join(package_dir, "package.json")), + %{} = package <- Jason.decode!(json), + types when is_binary(types) <- package["types"] || package["typings"] do + declaration_for(Path.join(package_dir, types)) + else + _ -> :error + end + end + + defp package_dir(name, from) do + case PackageResolver.find_node_modules(from) do + nil -> :error + node_modules -> {:ok, Path.join(node_modules, name)} + end + end + + # Each reached package also keeps its `package.json` and license files. + defp package_files(path, node_modules) do + package_dir = + Path.join(node_modules, path |> Path.relative_to(node_modules) |> package_name()) + + Path.wildcard(Path.join(package_dir, "{package.json,LICENSE*,license*}")) + end + + defp sources(dir) do + dir + |> Path.join("**/*") + |> Path.wildcard() + |> Enum.reject(&String.contains?(Path.relative_to(&1, dir), "node_modules")) + |> Enum.filter(&(Path.extname(&1) in @source_extensions)) + end + + # Import sources, including type-only imports, re-exports, and `import()` types. + defp imports(path) do + with {:ok, source} <- File.read(path), + {:ok, ast} <- OXC.parse(source, Path.basename(path)) do + OXC.collect(ast, fn + %{source: %{value: specifier}} when is_binary(specifier) -> {:keep, specifier} + _node -> :skip + end) + else + _ -> [] + end + end + + defp relative?(specifier), do: String.starts_with?(specifier, [".", "/"]) + + defp package_name("@" <> _ = specifier), + do: specifier |> String.split("/") |> Enum.take(2) |> Enum.join("/") + + defp package_name(specifier), do: specifier |> String.split("/", parts: 2) |> hd() + + # `@scope/pkg/sub` is typed by `@types/scope__pkg/sub`, `pkg/sub` by `@types/pkg/sub`. + defp types_specifier(specifier) do + name = package_name(specifier) + subpath = String.replace_prefix(specifier, name, "") + "@types/" <> String.replace(String.trim_leading(name, "@"), "/", "__") <> subpath + end +end diff --git a/lib/volt/tailwind/loader.ex b/lib/volt/tailwind/loader.ex index 8e550ab..042f77e 100644 --- a/lib/volt/tailwind/loader.ex +++ b/lib/volt/tailwind/loader.ex @@ -83,7 +83,11 @@ defmodule Volt.Tailwind.Loader do {code, dependencies} {:error, errors} -> - raise "Could not bundle Tailwind module #{inspect(entry_path)}: #{inspect(errors)}" + raise OXC.Error, + message: + "Could not bundle Tailwind module #{inspect(entry_path)}:\n" <> + Enum.map_join(errors, "\n", &OXC.Diagnostic.format/1), + errors: errors end else {:error, reason} -> diff --git a/lib/volt/test/browser_runner.ex b/lib/volt/test/browser_runner.ex index 1ad445b..a879c85 100644 --- a/lib/volt/test/browser_runner.ex +++ b/lib/volt/test/browser_runner.ex @@ -56,7 +56,7 @@ defmodule Volt.Test.BrowserRunner do end defp browser_runtime_code do - Volt.JS.Runtime.Bundler.bundle_file(Volt.Priv.path({:volt, "ts"}, "test/browser.ts")) + Volt.Priv.bundle({:volt, "ts"}, "test/browser.ts") end defp ensure_playwright_started(%Config{} = config, timeout) do diff --git a/lib/volt/test/result.ex b/lib/volt/test/result.ex index b293afb..4358513 100644 --- a/lib/volt/test/result.ex +++ b/lib/volt/test/result.ex @@ -3,7 +3,7 @@ defmodule Volt.Test.Result do defmodule Metadata do @moduledoc "Metadata collected for a registered JavaScript test before execution." - use JSONCodec, case: :camel, fast_path: :json + use JSONCodec, case: :camel defstruct [:id, :name, :full_name, :suite, :mode, :skip_reason, tags: [], line: nil] @@ -21,7 +21,7 @@ defmodule Volt.Test.Result do defmodule SerializedError do @moduledoc "Serialized JavaScript error details returned by a failed test." - use JSONCodec, fast_path: :json + use JSONCodec defstruct [:name, :message, :stack, :expected, :actual] @@ -36,7 +36,7 @@ defmodule Volt.Test.Result do defmodule Test do @moduledoc "Execution result for one registered JavaScript test." - use JSONCodec, case: :camel, fast_path: :json + use JSONCodec, case: :camel defstruct [:id, :name, :full_name, :status, :duration, :error, :skip_reason] @@ -51,7 +51,7 @@ defmodule Volt.Test.Result do } end - use JSONCodec, fast_path: :json + use JSONCodec defstruct [:file, :status, :duration, :total, :failed, :skipped, tests: []] diff --git a/lib/volt/test/sigils.ex b/lib/volt/test/sigils.ex index a262c61..e17143b 100644 --- a/lib/volt/test/sigils.ex +++ b/lib/volt/test/sigils.ex @@ -63,8 +63,13 @@ defmodule Volt.Test.Sigils do defp validate!(source, filename) do case OXC.parse(source, filename) do - {:ok, _ast} -> :ok - {:error, errors} -> raise ArgumentError, "invalid #{filename} source: #{inspect(errors)}" + {:ok, _ast} -> + :ok + + {:error, errors} -> + raise ArgumentError, + "invalid #{filename} source:\n" <> + Enum.map_join(errors, "\n", &OXC.Diagnostic.format/1) end end end diff --git a/lib/volt/watcher.ex b/lib/volt/watcher.ex index 7fdae3a..b732eef 100644 --- a/lib/volt/watcher.ex +++ b/lib/volt/watcher.ex @@ -164,17 +164,7 @@ defmodule Volt.Watcher do watch_ignored: watch_ignored } - if config[:tailwind] do - initial_tailwind_build( - tailwind_sources, - config[:tailwind_css], - tailwind_outdir, - tailwind_key, - tailwind_name, - config[:tailwind_runtime], - config[:tailwind_worker] - ) - end + if config[:tailwind], do: initial_tailwind_build(state) {:ok, refresh_tailwind_inputs(state)} end @@ -233,13 +223,22 @@ defmodule Volt.Watcher do defp watcher_sources(dirs), do: Enum.map(dirs, &%{base: &1, pattern: "**/*"}) - defp initial_tailwind_build(sources, css_path, outdir, key, name, runtime, worker) do - case build_tailwind(sources, css_path, outdir, key, name, runtime, worker) do + defp initial_tailwind_build(%{config: config} = state) do + case build_tailwind( + config.tailwind_sources, + config[:tailwind_css], + state.tailwind_outdir, + config.tailwind_key, + config.tailwind_name, + config[:tailwind_runtime], + config[:tailwind_worker] + ) do {:ok, css} -> Logger.debug("[Volt] Initial Tailwind build: #{byte_size(css)} bytes") {:error, reason} -> Logger.warning("[Volt] Initial Tailwind build failed: #{inspect(reason)}") + tailwind_error(reason, state) end end @@ -434,13 +433,14 @@ defmodule Volt.Watcher do state.tables || state.session ) + HMR.clear_error(path, session: state.session) changes = if css?, do: [:style], else: detect_changes(old_entry, result) broadcast_change(path, relative, changes, state) broadcast_css_dependents(css_dependents, state) broadcast_glob_dependents(path, state) {:error, reason} -> - HMR.broadcast(:error, %{path: relative, reason: reason}, session: state.session) + HMR.error(path, reason, session: state.session) end {:error, reason} when reason in [:enoent, :eacces, :eperm] -> @@ -448,11 +448,13 @@ defmodule Volt.Watcher do Volt.HMR.GlobGraph.remove(path, state.tables || state.session) if css?, do: Volt.HMR.StyleGraph.remove(path, state.tables || state.session) Volt.HMR.ModuleGraph.remove_file(path, state.tables || state.session) + HMR.clear_error(path, session: state.session) HMR.update(relative, [:full], session: state.session) broadcast_glob_dependents(path, state) {:error, reason} -> - HMR.broadcast(:error, %{path: relative, reason: inspect(reason)}, session: state.session) + message = "Could not read #{relative}: #{:file.format_error(reason)}" + HMR.error(path, message, session: state.session) end end @@ -603,6 +605,8 @@ defmodule Volt.Watcher do state.config[:tailwind_worker] ) do {:ok, css} -> + clear_tailwind_error(state) + if previous != {:ok, css} do HMR.broadcast(:update, %{path: state.config.tailwind_url, changes: [:style]}, session: state.session @@ -613,7 +617,7 @@ defmodule Volt.Watcher do :ok {:error, reason} -> - tailwind_error(reason, state.session) + tailwind_error(reason, state) end end @@ -641,6 +645,7 @@ defmodule Volt.Watcher do ] ) do Volt.Tailwind.Artifact.write(state.tailwind_outdir, state.config.tailwind_name, css) + clear_tailwind_error(state) HMR.broadcast(:update, %{path: state.config.tailwind_url, changes: [:style]}, session: state.session @@ -653,7 +658,7 @@ defmodule Volt.Watcher do :ok {:error, reason} -> - tailwind_error(reason, state.session) + tailwind_error(reason, state) end end @@ -668,11 +673,16 @@ defmodule Volt.Watcher do if worker, do: Volt.Tailwind.Worker.stylesheet(worker), else: {:error, :not_built} end - defp tailwind_error(reason, session) do - HMR.broadcast(:error, %{path: "tailwind", reason: inspect(reason)}, session: session) + defp tailwind_error(reason, state) do + HMR.error(tailwind_error_path(state), reason, session: state.session) {:error, reason} end + defp clear_tailwind_error(state), + do: HMR.clear_error(tailwind_error_path(state), session: state.session) + + defp tailwind_error_path(state), do: state.config[:tailwind_css] || "tailwind" + defp detect_changes(nil, _new), do: [:full] defp detect_changes(old_entry, new_result) do diff --git a/mix.exs b/mix.exs index 2218b05..d2ef56b 100644 --- a/mix.exs +++ b/mix.exs @@ -38,10 +38,10 @@ defmodule Volt.MixProject do [ {:reach, "~> 2.6.1", only: [:dev, :test], runtime: false}, {:glob_ex, "~> 0.1.12"}, - {:oxc, "~> 0.17.8"}, - {:vize, "~> 0.14.2"}, + {:oxc, "~> 0.18.1"}, + {:vize, "~> 0.15.0"}, {:oxide_ex, "~> 0.2.2"}, - {:quickbeam, "~> 0.11.1"}, + {:quickbeam, "~> 0.11.2"}, {:dotenvy, "~> 1.1"}, {:floki, "~> 0.38"}, {:plug, "~> 1.16"}, @@ -49,7 +49,9 @@ defmodule Volt.MixProject do {:websock_adapter, "~> 0.5"}, {:file_system, "~> 1.0"}, {:jason, "~> 1.4"}, - {:json_codec, "~> 0.2.3"}, + {:json_codec, "~> 0.3"}, + {:lumis, "~> 0.10", optional: true}, + {:lumis_wasm_typescript, "~> 0.26", only: :test}, {:igniter, ">= 0.8.4 and < 1.0.0", optional: true}, {:npm, "~> 0.7.6"}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, @@ -73,7 +75,9 @@ defmodule Volt.MixProject do "reach.check --arch --dead-code --smells --strict", "dialyzer" ], - setup: ["deps.get"], + setup: ["deps.get", "volt.priv.vendor priv/ts"], + "hex.build": ["cmd mix volt.priv.vendor priv/ts", "hex.build"], + "hex.publish": ["cmd mix volt.priv.vendor priv/ts", "hex.publish"], ci: ["lint", "cmd env MIX_ENV=test mix test"] ] end diff --git a/mix.lock b/mix.lock index eebb01e..6deded8 100644 --- a/mix.lock +++ b/mix.lock @@ -17,21 +17,24 @@ "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, "ex_slop": {:hex, :ex_slop, "0.4.2", "142aba9a82eddfb258e39c45d59392ab3cdb6b5a3ad401b09b362b7134fc54eb", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "c7f5316f755f83566e7a0a049f6fedfcd5ff916fce83c6ebfdf806be62fd7a69"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, + "finch": {:hex, :finch, "0.24.0", "4022b6194e907b6d9b597c168001cac38e488a35fe36cef9d8297e3159782510", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "33ba40069c3587c2f99f9125b766e19dad87d6d54be3c6961db2304df04cef00"}, "floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"}, "glob_ex": {:hex, :glob_ex, "0.1.12", "7b2d9369c20e2697efcfd185d13d6e84c94cd3bfd2730fbde613141c2e015c00", [:mix], [], "hexpm", "2e2fac83f113514434c7eaf267b4c38af2f91766f1cab2c5db7053b7fc1ee0bb"}, "hex_solver": {:hex, :hex_solver, "0.3.0", "81e7659ad6caba1f856d89fc7ca52f88e8eb4f801deedcdce215f9006d706d1d", [:mix], [], "hexpm", "8a04c8ef0df25ca1f5e4d7d5f32833fac569f5c6442f05e216ee33615593e64d"}, - "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, "igniter": {:hex, :igniter, "0.8.4", "f79f1bbdc2fb7b9ca030a22d12a585b060cbf5b94b9d3f23b1148578a9e05d11", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "a9b1cbec996ccb100b4f7d8130129b2dd3f18eb4224ac9a0e907e428ca90dbd7"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, - "json_codec": {:hex, :json_codec, "0.2.3", "b75b2f76a2c89844a72f2dcc8f83c045d0fb030da041b9c2278c82f70067ec78", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4dad674cbee1119161b555a155ef1537908ee337d49e619e1b155313ec523b71"}, + "json_codec": {:hex, :json_codec, "0.3.1", "81f738e212308c85344e11da00af6d8b52bd486224ee24c1f51b95df0604aeae", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:json_spec, "~> 1.2", [hex: :json_spec, repo: "hexpm", optional: false]}], "hexpm", "dffea3198d3f1fe9a39aa176ab0d6988b8c80dd85a67e913b323d4ec7c57ca9d"}, + "json_spec": {:hex, :json_spec, "1.2.0", "c67f73cdc4166e955b2f1095d3f44dd78c197337038f1a5301fb526b6c87ab04", [:mix], [], "hexpm", "3639961babad7d6bc45f3a2d96a49c18ec70614761bb84ce58b64e9fad9354de"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, + "lumis": {:hex, :lumis, "0.10.0", "4b0b773447f7dace8be7b0c93abe4d24e9b7c81034ac2757003f3ac4b7ac3450", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "5e3845defa32871a404d6a51ad2d8d3c423077f6f80149e632511f26460f27dc"}, + "lumis_wasm_typescript": {:hex, :lumis_wasm_typescript, "0.26.2", "6f6972ba900860bfa5b601333a216faa24a9e1f59dac84b2beae3911941b006b", [:mix], [], "hexpm", "277acc22eb48133299e137ba5f127bf635409192dd5f5c6f5839e52fd7c0bc72"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "makeup_js": {:hex, :makeup_js, "0.1.0", "ffa8ce9db95d14dcd09045334539d5992d540d63598c592d4805b7674bdd6675", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "3f0c1a5eb52c9737b1679c926574e83bb260ccdedf08b58ee96cca7c685dea75"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.10.0", "85af3353bfc504f5bdfe494bd92b8490f87a306dc659ee1ad0af435107e898dc", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "8b16fb72aaa7531d206a1f05e4cc85509ba531ccec7a17a22736c9c95cbb24d1"}, + "mint": {:hex, :mint, "1.11.0", "a713551624815c0435237b93d90ea8b9b14254690c66f732d0ef8930f76ff1d9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "c6279ba2d6aa3a383a1d4cfbe7b59f42e6efd400f58d8e2acfeac48a438693ab"}, "mint_web_socket": {:hex, :mint_web_socket, "1.0.6", "5ffcf350df5b90f2d7a04adf877165228804993714592512374218d4679e325a", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "0c360e9012413f1c115a63532601eb5d63731aab7010949178769760686c1698"}, "muontrap": {:hex, :muontrap, "1.8.0", "3c5dab5bc91d5a16d14cd85b130ae7d35a9c469564a11ba4b2ebbcd7ccf5bc05", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "45ff36451303cbdefe1c66da3681b5aff078ee5737348f554f0017daa66284af"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, @@ -40,7 +43,7 @@ "npm": {:hex, :npm, "0.7.6", "de8ca3dd26a40fac7ab8a17680088b8d55bab0e0294be9770b029c276361482f", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:npm_semver, "~> 0.1.0", [hex: :npm_semver, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "e0a1ce77e3e49ec108e095aba133bd315e86da298dba26b142a105972141d9ef"}, "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, - "oxc": {:hex, :oxc, "0.17.8", "e4c50ee0c0ce09107016b5153132b3d98421665355189b531c78ae4603047853", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "590fddfadbf843d6ab94e0f04a0e769a38358a23140728095da476c8c81f4df1"}, + "oxc": {:hex, :oxc, "0.18.1", "9fb4cd232ee2143364e86a021992958b1aecf07583432ebb3319386e2f6ce6c0", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "2653908736b6064c8363415280472bc687ad3028ee7f1441cb6ac458fc8e9a62"}, "oxide_ex": {:hex, :oxide_ex, "0.2.2", "063e0d2899e81632b7efc7849eb1dd89a7a623290a075c446b43bef14cf5795e", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "577a2fbd97c46904a31143bb700cdb374105085cfd1f7605416bb6c012cb7f60"}, "pegasus": {:hex, :pegasus, "0.2.6", "b4af6522326fbb2ffd1bb706e78ec05854fbcb4b03a7fe08f8db2e9de1d0be67", [:mix], [{:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0ac159f0ccab7967cf90208327cc8a35788874814c8d78e19d47104d3fc049b9"}, "pi_bridge": {:hex, :pi_bridge, "0.6.21", "f1a63da5f49fef4f6b35e98edd5400cbd64a49fb8bda84be41b7e91f3deb0b89", [:mix], [{:bandit, "~> 1.8", [hex: :bandit, repo: "hexpm", optional: false]}, {:dune, "~> 0.3", [hex: :dune, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_ast, "~> 0.12", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.1.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:quackdb, "~> 0.5.4", [hex: :quackdb, repo: "hexpm", optional: false]}, {:reach, "~> 2.6", [hex: :reach, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:req_llm, "~> 1.6", [hex: :req_llm, repo: "hexpm", optional: true]}], "hexpm", "b226fb60965a8e928186af3c3b96fe8f0fefd8c6cc7be679d6e169ba84105de9"}, @@ -49,19 +52,19 @@ "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, "protoss": {:hex, :protoss, "1.1.0", "853533313989751c7da5b0e1ce71501a23f3dc41f128709478af40daccc6e959", [:mix], [], "hexpm", "c2f874383dd047fcfdf467b814dd33a208a4d24a467aef90d86185b41a0752ad"}, "quackdb": {:hex, :quackdb, "0.5.13", "854294a5a85e0f2972d2229da8dfb25afd2cda500e80e477f6987bff823c2b21", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.7", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:explorer, "~> 0.11", [hex: :explorer, repo: "hexpm", optional: true]}, {:fsst, "~> 0.1.2", [hex: :fsst, repo: "hexpm", optional: true]}, {:geo, "~> 4.1", [hex: :geo, repo: "hexpm", optional: true]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:muontrap, "~> 1.5", [hex: :muontrap, repo: "hexpm", optional: false]}, {:table, "~> 0.1", [hex: :table, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:varint, "~> 1.6", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "a5d18e54de682bde9f4ab1866126aa90c26223866bc693bf56cdf9dafa3859d3"}, - "quickbeam": {:hex, :quickbeam, "0.11.1", "55e7700b2a16c8df246402f8400908222e906f3b463af4ac8706f748d83b66db", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.10", [hex: :mint, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.6", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.17.8", [hex: :oxc, repo: "hexpm", optional: false]}, {:varint, "~> 1.6", [hex: :varint, repo: "hexpm", optional: false]}, {:zigler, "~> 0.16.0", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.6", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "6c0240b9d016bbc174319ef004e81f2893ae2e77939d6b9b87c5542b344d0b87"}, + "quickbeam": {:hex, :quickbeam, "0.11.2", "ea4e4bfd7a8999381f86b773b13d028c02753137f8b56c1d3f3cce4649dfee71", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: true]}, {:mint, "~> 1.10", [hex: :mint, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.6", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.17.8 or ~> 0.18.0", [hex: :oxc, repo: "hexpm", optional: false]}, {:varint, "~> 1.6", [hex: :varint, repo: "hexpm", optional: false]}, {:zigler, "~> 0.16.0", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.6", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "f7652948f43484fcc5c0fe007fdcddbd62db595daa16a6f53fb9b3553f66c608"}, "reach": {:hex, :reach, "2.6.1", "33f749a1e15baa843aedd3e3e9a282322eef1e6c59d9b8a5072c8427359f9658", [:mix], [{:boxart, "~> 0.3.3", [hex: :boxart, repo: "hexpm", optional: true]}, {:ex_ast, "~> 0.12.0", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:ex_dna, "~> 1.5", [hex: :ex_dna, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:libgraph, "~> 0.16.0", [hex: :libgraph, repo: "hexpm", optional: false]}, {:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: true]}, {:makeup_js, "~> 0.1", [hex: :makeup_js, repo: "hexpm", optional: true]}, {:quickbeam, "~> 0.10", [hex: :quickbeam, repo: "hexpm", optional: true]}], "hexpm", "2d7e8a26a999be50cf716cf5786c5d1570d04b397ee047d430cab1d6956714cc"}, "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.10.0", "8f07ff0865cb09a6a0f75e11701e9a46e331e05f737da4e5b01374dcd25b68c0", [:mix], [{:rustler, "~> 0.28", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "c68c9dc732c12acfdf4a7fa06f4e6f48a641179e6be49a474c8a60809929c430"}, "sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"}, "spitfire": {:hex, :spitfire, "0.4.1", "69e90335d00ca328295e1e1e77cac5d7575aa6d34274e3467ebfc654b8858be3", [:mix], [], "hexpm", "27d86f67681179682b15c6758d64ac2eb2b3637ed8340800c8b885c69754cdcd"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, "varint": {:hex, :varint, "1.6.0", "7bced828599b2eb84491a9f067f8a5a67fc35a946d3b7582278083c7fd434b00", [:mix], [], "hexpm", "2b4f4a20650aeebe993a70b03bb99571bcbddc27c361322c28b586d8a772ce4e"}, - "vize": {:hex, :vize, "0.14.2", "4baf40c18e00963ce802c03ebb664feaccd8558d7ec62aa43fb07ad8485d0208", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8336744616bc27ce4edc7448cda6f0b8a481cbec9035d3c4d6bf3229be91386d"}, + "vize": {:hex, :vize, "0.15.0", "f13eaecaa6a59c3314e374febc6b21e127560e08836f5d3e8edc4282412d182e", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "e1f2021c1e43d1f7530081dbf00ea93f6e55ea6928d10371a64e1214605316a0"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "zig_get": {:hex, :zig_get, "0.15.2", "a6ccaa894213839ba95615bf9be2b2c9268e37ab9547a2344830202cfd6d7cc0", [:mix], [], "hexpm", "e6b0028f2d5a8da791ff8037deff5b492784017d8d241e598377a24bd765f56f"}, diff --git a/priv/ts/client/hmr.ts b/priv/ts/client/hmr.ts index 5310879..e04132a 100644 --- a/priv/ts/client/hmr.ts +++ b/priv/ts/client/hmr.ts @@ -5,7 +5,7 @@ import { preserveHotData, type HotCallback } from './hot' -import { renderErrorOverlay } from './overlay' +import { clearErrorOverlay, renderErrorOverlay, type VoltError } from './overlay' import { removeStyle, updateStyle, updateStyles } from './styles' export { createHotContext, removeStyle, updateStyle } @@ -19,6 +19,7 @@ let reconnectTimer: ReturnType | undefined let heartbeatTimer: ReturnType | undefined let lastPongAt = 0 let reconnectAttempts = 0 +let connected = false const HEARTBEAT_INTERVAL = __VOLT_HEARTBEAT__ const PONG_GRACE = HEARTBEAT_INTERVAL * 2 @@ -30,6 +31,13 @@ function connect() { ws = new WebSocket(`${proto}//${location.host}/@volt/ws`) ws.onopen = () => { + // The server may have restarted while we were away, so the page can be stale. + if (connected) { + location.reload() + return + } + + connected = true console.log('[Volt] HMR connected') if (reconnectTimer) { @@ -72,7 +80,7 @@ function connect() { ) break case 'error': - showOverlay(payload.reason) + showErrors(payload.errors as VoltError[]) break case 'full-reload': location.reload() @@ -193,8 +201,12 @@ function importVersion(url: string, timestamp: number) { return import(/* @vite-ignore */ `${url}${url.includes('?') ? '&' : '?'}t=${timestamp}`) } -function showOverlay(reason: unknown) { - renderErrorOverlay(reason, { title: 'Build error', dismissible: true }) +function showErrors(errors: VoltError[]) { + if (errors.length > 0) { + renderErrorOverlay(errors) + } else { + clearErrorOverlay() + } } connect() diff --git a/priv/ts/client/overlay.ts b/priv/ts/client/overlay.ts index 934bb07..2a5f4e7 100644 --- a/priv/ts/client/overlay.ts +++ b/priv/ts/client/overlay.ts @@ -1,35 +1,181 @@ -const VOLT_ERROR_OVERLAY_ID = 'volt-error-overlay' -const VOLT_ERROR_OVERLAY_STYLE = - 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.85);color:#ff6b6b;font:14px/1.6 monospace;padding:2em;white-space:pre-wrap;overflow:auto' +import { html, render } from 'lit-html' +import { unsafeHTML } from 'lit-html/directives/unsafe-html.js' -type VoltErrorOverlayOptions = { - title?: string - dismissible?: boolean +export type VoltError = { + title: string + message: string + file?: string | null + line?: number | null + column?: number | null + hint?: string | null + frame?: string | null + frame_html?: string | null + stack?: string | null } -export function renderErrorOverlay(reason: unknown, options: VoltErrorOverlayOptions = {}) { - const title = options.title ?? 'Build error' - console.error(`[Volt] ${title}:\n${messageFor(reason)}`) +const TAG = 'volt-error-overlay' + +// Colors from the Volt logo: purple to cyan on deep purple. +const styles = html`` + +class VoltErrorOverlay extends HTMLElement { + private root = this.attachShadow({ mode: 'open' }) + + constructor() { + super() + this.addEventListener('click', (event) => { + const target = event.composedPath()[0] + if (target instanceof Element && target.classList.contains('backdrop')) this.remove() + }) + } + + show(errors: VoltError[]) { + const title = heading(errors) + + render( + html`${styles} +
+
+
+

${errors.length > 1 ? `${title} · ${errors.length}` : title}

+ ${errors.map(errorTemplate)} +
Fix the error to dismiss this overlay, or click outside it.
+
+
+
`, + this.root + ) + } +} + +function errorTemplate(error: VoltError) { + const location = locationText(error) + + return html`
+ ${location ? html`
${location}
` : null} +
${error.message}
+ ${ + error.frame + ? // The dev server builds `frame_html` from escaped, syntax-highlighted source. + html`
${error.frame_html ? unsafeHTML(error.frame_html) : error.frame}
` + : null + } + ${error.hint ? html`
${error.hint}
` : null} + ${error.stack ? html`
${error.stack}
` : null} +
` +} + +export function renderErrorOverlay(errors: VoltError[]) { + console.error(`[Volt] ${heading(errors)}:\n${errors.map(errorText).join('\n\n')}`) if (typeof document === 'undefined') { return } - let overlay = document.getElementById(VOLT_ERROR_OVERLAY_ID) + if (!customElements.get(TAG)) customElements.define(TAG, VoltErrorOverlay) + + clearErrorOverlay() + const overlay = document.createElement(TAG) as VoltErrorOverlay + overlay.show(errors) + document.body.appendChild(overlay) +} - if (!overlay) { - overlay = document.createElement('div') - overlay.id = VOLT_ERROR_OVERLAY_ID - document.body.appendChild(overlay) +export function clearErrorOverlay() { + if (typeof document !== 'undefined') { + document.querySelector(TAG)?.remove() } +} + +// The errors' shared title, or a neutral one when they differ. +function heading(errors: VoltError[]) { + const titles = new Set(errors.map((error) => error.title)) + return titles.size === 1 ? [...titles][0] : 'Errors' +} - overlay.style.cssText = options.dismissible - ? `${VOLT_ERROR_OVERLAY_STYLE};cursor:pointer` - : VOLT_ERROR_OVERLAY_STYLE - overlay.onclick = options.dismissible ? () => overlay?.remove() : null - overlay.textContent = `[Volt] ${title}:\n\n${messageFor(reason)}` +function locationText({ file, line, column }: VoltError) { + if (!file) return null + if (!line) return file + return column ? `${file}:${line}:${column}` : `${file}:${line}` } -function messageFor(reason: unknown) { - return typeof reason === 'string' ? reason : JSON.stringify(reason, null, 2) +function errorText(error: VoltError) { + return [locationText(error), error.message, error.frame, error.hint].filter(Boolean).join('\n') } diff --git a/priv/ts/npm.lock b/priv/ts/npm.lock new file mode 100644 index 0000000..59e1ac7 --- /dev/null +++ b/priv/ts/npm.lock @@ -0,0 +1,31 @@ +{ + "lockfileVersion": 1, + "packages": { + "@types/trusted-types": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "version": "2.0.7" + }, + "lit-html": { + "dependencies": { + "@types/trusted-types": "^2.0.2" + }, + "has_install_script": false, + "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz", + "version": "3.3.3" + } + }, + "policy": { + "allow_registry_redirects": false, + "allowed_registries": [ + "https://registry.npmjs.org" + ], + "block_exotic_subdeps": true, + "exotic_deps": [] + } +} diff --git a/priv/ts/package.json b/priv/ts/package.json new file mode 100644 index 0000000..24d2899 --- /dev/null +++ b/priv/ts/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "dependencies": { + "lit-html": "3.3.3" + } +} diff --git a/test/mix/tasks/volt/js/check_test.exs b/test/mix/tasks/volt/js/check_test.exs index 85a0b60..99c6e30 100644 --- a/test/mix/tasks/volt/js/check_test.exs +++ b/test/mix/tasks/volt/js/check_test.exs @@ -47,7 +47,7 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do File.write!(file, "document; describe; knownGlobal(); missingGlobal();\n") Application.put_env(:volt, :lint, - env: [:browser, :mocha], + env: ["browser", "mocha"], globals: %{"knownGlobal" => :readonly}, rules: %{"no-undef" => :deny} ) @@ -78,15 +78,63 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do assert output =~ "typescript/no-floating-promises" end + test "category-only configuration submits semantic rules to tsgolint" do + file = Path.join(@tmp_dir, "typed.ts") + File.write!(file, "Promise.resolve(1);\n") + + Application.put_env(:volt, :lint, + tsgolint: fake_tsgolint!(@tmp_dir), + plugins: ["typescript"], + rules: %{"correctness" => :deny} + ) + + diagnostics = Volt.JS.Check.lint([file], type_aware: true) + + assert Enum.any?( + diagnostics, + &(&1.rule == "typescript/no-floating-promises" and &1.severity == :error) + ) + end + + test "category expansion honors per-file semantic rule exclusions" do + first = Path.join(@tmp_dir, "first.ts") + second = Path.join(@tmp_dir, "second.ts") + Enum.each([first, second], &File.write!(&1, "Promise.resolve(1);\n")) + payload_path = Path.join(@tmp_dir, "categories.jsonl") + + tsgolint = + fake_executable!(@tmp_dir, "tsgolint-categories", """ + input = IO.binread(:stdio, :eof) + File.write!(#{inspect(payload_path)}, [input, "\\n"], [:append]) + """) + + Application.put_env(:volt, :lint, + root: @tmp_dir, + tsgolint: tsgolint, + plugins: ["typescript"], + rules: %{"correctness" => :deny}, + overrides: [%{files: ["second.ts"], rules: %{"typescript/no-floating-promises" => :allow}}] + ) + + assert [] = Volt.JS.Check.lint([first, second], type_aware: true) + batches = payload_path |> File.stream!() |> Enum.map(&Jason.decode!/1) + configs = Enum.flat_map(batches, & &1["configs"]) + + for {file, selected} <- [{first, true}, {second, false}] do + config = Enum.find(configs, &(Path.expand(file) in &1["file_paths"])) + assert Enum.any?(config["rules"], &(&1["name"] == "no-floating-promises")) == selected + end + end + test "type-check diagnostics are promoted to errors" do diagnostic = %{ rule: "typescript/TS2322", - severity: :warn, + severity: :warning, file: "typed.ts", message: "Type number is not assignable to type string." } - assert %{severity: :deny} = + assert %{severity: :error} = Volt.JS.Check.promote_type_check_diagnostic(diagnostic, type_check: true) end @@ -110,7 +158,13 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do end) payload = @tmp_dir |> Path.join("payload.json") |> File.read!() |> Jason.decode!() - assert [%{"rules" => [%{"name" => "no-floating-promises"}]}] = payload["configs"] + assert [%{"rules" => rules}] = payload["configs"] + assert Enum.any?(rules, &(&1["name"] == "no-floating-promises")) + + refute Enum.any?( + rules, + &(&1["name"] in ["correctness", "suspicious", "consistent-type-imports"]) + ) end test "type-aware check submits framework single-file component scripts as virtual files" do @@ -168,7 +222,7 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do File.write!(#{inspect(payload_path)}, [input, "\\n"], [:append]) payload = JSON.decode!(input) for config <- payload["configs"], file <- config["file_paths"] do - json = JSON.encode!(%{rule: "no-floating-promises", message: %{description: "batch diagnostic"}, file_path: file, range: %{pos: 0, end: 1}}) + json = JSON.encode!(%{kind: 0, rule: "no-floating-promises", message: %{id: "floatingPromise", description: "batch diagnostic"}, file_path: file, range: %{pos: 0, end: 1}}) IO.binwrite(<>) end """) @@ -176,7 +230,7 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do Application.put_env(:volt, :lint, root: @tmp_dir, tsgolint: tsgolint, - rules: %{"typescript/no-floating-promises" => :deny}, + rules: %{"correctness" => :deny, "typescript/no-floating-promises" => :deny}, overrides: [ %{files: ["**/*.{vue,svelte}"], rules: %{"typescript/no-floating-promises" => :warn}}, %{files: ["**/*.script0.ts"], rules: %{"typescript/no-floating-promises" => :allow}} @@ -196,13 +250,13 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do configs = Enum.flat_map(batches, & &1["configs"]) assert Enum.sort(Enum.map(configs, &length(&1["file_paths"]))) == [1, 2] - assert configs |> Enum.flat_map(& &1["rules"]) |> Enum.map(& &1["name"]) |> Enum.uniq() == [ - "no-floating-promises" - ] + names = configs |> Enum.flat_map(& &1["rules"]) |> Enum.map(& &1["name"]) + assert "no-floating-promises" in names + refute "correctness" in names - assert Enum.find(diagnostics, &(&1.file == Path.expand(app))).severity == :deny - assert Enum.find(diagnostics, &(&1.file == vue)).severity == :warn - assert Enum.find(diagnostics, &(&1.file == svelte)).severity == :warn + assert Enum.find(diagnostics, &(&1.file == Path.expand(app))).severity == :error + assert Enum.find(diagnostics, &(&1.file == vue)).severity == :warning + assert Enum.find(diagnostics, &(&1.file == svelte)).severity == :warning for batch <- batches do assert batch["source_overrides"][Path.expand(vue <> ".script0.ts")] =~ "vueValue" @@ -212,7 +266,7 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do defp fake_tsgolint!(dir) do fake_executable!(dir, "tsgolint", """ - json = ~s({"rule":"no-floating-promises","message":{"description":"floating promise"},"file_path":"typed.ts","range":{"pos":0,"end":5}}) + json = ~s({"kind":0,"rule":"no-floating-promises","message":{"id":"floatingPromise","description":"floating promise"},"file_path":"typed.ts","range":{"pos":0,"end":5}}) IO.binwrite(<>) """) end diff --git a/test/mix/tasks/volt/lint_test.exs b/test/mix/tasks/volt/lint_test.exs index 29adcfe..4682aa3 100644 --- a/test/mix/tasks/volt/lint_test.exs +++ b/test/mix/tasks/volt/lint_test.exs @@ -27,7 +27,7 @@ defmodule Mix.Tasks.Volt.LintTest do test "reports no issues for clean code" do File.write!(Path.join(@tmp_dir, "clean.ts"), "export const x = 1;\n") - Application.put_env(:volt, :lint, plugins: [:typescript]) + Application.put_env(:volt, :lint, plugins: ["typescript"]) output = capture_io(fn -> Mix.Tasks.Volt.Lint.run([]) end) assert output =~ "No issues found" @@ -50,7 +50,7 @@ defmodule Mix.Tasks.Volt.LintTest do File.write!(Path.join(@tmp_dir, "typed.ts"), "export function foo(x: any) { return x; }\n") Application.put_env(:volt, :lint, - plugins: [:typescript], + plugins: ["typescript"], rules: %{"typescript/no-explicit-any" => :warn} ) @@ -77,7 +77,7 @@ defmodule Mix.Tasks.Volt.LintTest do for file <- [generated, authored], do: File.write!(file, "export const value = 1;\n") Application.put_env(:volt, :lint, - plugins: [:unicorn], + plugins: ["unicorn"], rules: %{"unicorn/filename-case" => :deny}, overrides: [%{files: ["colocated/**/*.js"], rules: %{"unicorn/filename-case" => :allow}}] ) @@ -114,13 +114,13 @@ defmodule Mix.Tasks.Volt.LintTest do Application.put_env(:volt, :lint, root: @tmp_dir, - plugins: [:unicorn], - env: [:browser], + plugins: ["unicorn"], + env: ["browser"], rules: %{"no-undef" => :deny, "unicorn/no-null" => :deny}, overrides: [ %{ files: ["scripts/**/*.js"], - env: %{browser: false, node: true}, + env: %{"browser" => false, "node" => true}, globals: %{"externalValue" => :readonly}, rules: %{"unicorn/no-null" => :allow} } @@ -161,13 +161,13 @@ defmodule Mix.Tasks.Volt.LintTest do File.write!(file, "document.title = sharedGlobal;\nprocess.exitCode = 0;\n") Application.put_env(:volt, :lint, - env: [:browser], + env: ["browser"], globals: %{"sharedGlobal" => :readonly}, rules: %{"no-undef" => :deny}, overrides: [ %{ files: ["server.js"], - env: %{browser: false, node: true}, + env: %{"browser" => false, "node" => true}, globals: %{"sharedGlobal" => :off} } ] @@ -224,8 +224,8 @@ defmodule Mix.Tasks.Volt.LintTest do @impl true def run(ast, _ctx) do OXC.collect(ast, fn - %{type: :debugger_statement, start: s, end: e} -> - {:keep, %{span: {s, e}, message: "custom debugger ban"}} + %{type: :debugger_statement, start: start, end: stop} -> + {:keep, %{start: start, end: stop, message: "custom debugger ban"}} _ -> :skip diff --git a/test/support/volt/dev_server_case.ex b/test/support/volt/dev_server_case.ex index 0de24a2..3647926 100644 --- a/test/support/volt/dev_server_case.ex +++ b/test/support/volt/dev_server_case.ex @@ -98,8 +98,13 @@ defmodule Volt.TestSupport.DevServerCase do Volt.Cache.clear() Volt.HMR.StyleGraph.clear() Volt.HMR.ModuleGraph.clear() + Volt.HMR.Errors.clear_session(:default) + + on_exit(fn -> + Volt.HMR.Errors.clear_session(:default) + File.rm_rf!(@fixture_dir) + end) - on_exit(fn -> File.rm_rf!(@fixture_dir) end) :ok end diff --git a/test/volt/dev/error_test.exs b/test/volt/dev/error_test.exs new file mode 100644 index 0000000..473dff9 --- /dev/null +++ b/test/volt/dev/error_test.exs @@ -0,0 +1,86 @@ +defmodule Volt.Dev.ErrorTest do + use ExUnit.Case, async: true + + alias Volt.Dev.Error + + @moduletag :tmp_dir + + test "locates diagnostics and frames the source line", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "app.ts") + File.write!(path, "const a = 1\nconst b = 2\nconst = ;\nconst c = 3\nconst d = 4\n") + {:error, diagnostics} = OXC.parse(File.read!(path), "app.ts") + + assert [%{file: file, line: 3, column: 7, message: message, frame: frame}] = + Error.entries(diagnostics, file: path) + + assert file == Path.relative_to_cwd(path) + assert message =~ "Unexpected token" + + assert frame == """ + 1 | const a = 1 + 2 | const b = 2 + > 3 | const = ; + | ^ + 4 | const c = 3\ + """ + end + + test "highlights the frame with Lumis when a parser covers the language", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "app.ts") + File.write!(path, "/* a\n comment */\nconst a = 1 < 2\nconst = ;\n") + {:error, diagnostics} = OXC.parse(File.read!(path), "app.ts") + + assert [%{frame_html: html}] = Error.entries(diagnostics, file: path) + assert html =~ ~s(> 4 | ) + assert html =~ ~s( 3 | ) + assert html =~ ~s( comment */) + assert html =~ "<" + refute html =~ "1 < 2" + assert length(String.split(html, "\n")) == 5 + end + + test "keeps only the plain frame without a parser for the language", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "app.css") + File.write!(path, ".a { color: red\n") + + assert [%{frame: "> 1 | .a { color: red" <> _, frame_html: nil}] = + Error.entries(%{message: "x", position: {1, 2}}, file: path) + end + + test "keeps a diagnostic's own file when it differs from the source path" do + diagnostic = %{message: "boom", file: "other.ts", position: {2, 1}, details: "try this"} + + assert [%{file: "other.ts", line: 2, column: 1, hint: "try this", frame: nil}] = + Error.entries([diagnostic], file: "app.ts") + end + + test "formats a diagnostic's stacktrace" do + stacktrace = [{MyApp.Page, :render, 1, [file: ~c"lib/my_app/page.ex", line: 12]}] + + assert [%{stack: " lib/my_app/page.ex:12: MyApp.Page.render/1\n"}] = + Error.entries(%{message: "boom", stacktrace: stacktrace}) + + assert [%{stack: nil}] = Error.entries(%{message: "boom", stacktrace: []}) + end + + test "describes messages, exceptions, and other terms" do + assert [ + %{message: "plain", file: "app.ts", line: nil, frame: nil}, + %{message: "raised"}, + %{message: "{:unsupported, \".xyz\"}"} + ] = + Error.entries(["plain", RuntimeError.exception("raised"), {:unsupported, ".xyz"}], + file: "app.ts" + ) + end + + test "titles entries as build errors unless given a title" do + assert [%{title: "Build error"}] = Error.entries("boom") + assert [%{title: "Render error"}] = Error.entries("boom", title: "Render error") + end + + test "entries encode as JSON" do + {:error, diagnostics} = OXC.parse("const = ;", "app.ts") + assert {:ok, _json} = diagnostics |> Error.entries(file: "app.ts") |> Jason.encode() + end +end diff --git a/test/volt/dev_server/requests_test.exs b/test/volt/dev_server/requests_test.exs index 017185e..676be25 100644 --- a/test/volt/dev_server/requests_test.exs +++ b/test/volt/dev_server/requests_test.exs @@ -263,6 +263,30 @@ defmodule Volt.DevServer.RequestsTest do refute conn.halted end + test "loads the HMR client in pages the app renders" do + page = "x" + client = ~s() + + conn = + call_dev_server("/page") |> put_resp_content_type("text/html") |> send_resp(200, page) + + assert conn.resp_body == "x#{client}" + + loaded = "#{client}" + + conn = + call_dev_server("/page") |> put_resp_content_type("text/html") |> send_resp(200, loaded) + + assert conn.resp_body == loaded + + conn = + call_dev_server("/api") + |> put_resp_content_type("application/json") + |> send_resp(200, "{}") + + assert conn.resp_body == "{}" + end + test "serves static assets with correct MIME type" do File.write!(Path.join(@fixture_dir, "src/image.png"), "binary") conn = call_dev_server("/assets/image.png") @@ -331,12 +355,21 @@ defmodule Volt.DevServer.RequestsTest do end describe "error handling" do - test "returns 500 with error overlay for invalid source" do - File.write!(Path.join(@fixture_dir, "src/bad.ts"), "const = ;") + test "reports compile errors to the HMR client until the module compiles" do + path = Path.join(@fixture_dir, "src/bad.ts") + File.write!(path, "const = ;") + conn = call_dev_server("/assets/bad.ts") assert conn.status == 500 - assert conn.resp_body =~ "renderErrorOverlay" - assert conn.resp_body =~ "Compilation error" + assert conn.resp_body =~ "Could not compile" + + assert [%{line: 1, column: 7, frame: frame, file: file}] = Volt.HMR.Errors.list(:default) + assert file == Path.relative_to_cwd(path) + assert frame =~ "> 1 | const = ;" + + File.write!(path, "export const ok = 1") + assert call_dev_server("/assets/bad.ts").status == 200 + assert Volt.HMR.Errors.list(:default) == [] end end end diff --git a/test/volt/hmr/socket_test.exs b/test/volt/hmr/socket_test.exs index 15d0a48..6b6c665 100644 --- a/test/volt/hmr/socket_test.exs +++ b/test/volt/hmr/socket_test.exs @@ -1,12 +1,26 @@ defmodule Volt.HMR.SocketTest do use ExUnit.Case, async: false + setup do + Volt.HMR.Errors.clear_session(:default) + on_exit(fn -> Volt.HMR.Errors.clear_session(:default) end) + end + describe "init/1" do test "registers with registry" do {:ok, _state} = Volt.HMR.Socket.init(nil) me = self() assert {me, nil} in Registry.lookup(Volt.HMR.Registry, :clients) end + + test "sends current errors to a new client" do + Volt.HMR.error("app.ts", "boom") + + {:push, {:text, json}, _state} = Volt.HMR.Socket.init(nil) + + assert %{"type" => "error", "payload" => %{"errors" => [%{"message" => "boom"}]}} = + Jason.decode!(json) + end end describe "handle_info/2" do diff --git a/test/volt/hmr_test.exs b/test/volt/hmr_test.exs index c3769a5..fd01811 100644 --- a/test/volt/hmr_test.exs +++ b/test/volt/hmr_test.exs @@ -3,7 +3,8 @@ defmodule Volt.HMRTest do setup do Registry.register(Volt.HMR.Registry, :clients, nil) - :ok + Volt.HMR.Errors.clear_session(:default) + on_exit(fn -> Volt.HMR.Errors.clear_session(:default) end) end test "scoped broadcasts do not reach other sessions or default subscribers" do @@ -68,9 +69,20 @@ defmodule Volt.HMRTest do assert_receive {:volt_hmr, :update, %{path: "app.css", changes: ["style"]}} end - test "error broadcasts error payload" do - assert :ok = Volt.HMR.error("index.html", "boom") - assert_receive {:volt_hmr, :error, %{path: "index.html", reason: "boom"}} + test "error broadcasts the current errors until they are cleared" do + assert :ok = Volt.HMR.error("index.html", "boom", title: "Render error") + + assert_receive {:volt_hmr, :error, + %{errors: [%{message: "boom", file: "index.html", title: "Render error"}]}} + + assert :ok = Volt.HMR.error("app.ts", "bang") + assert_receive {:volt_hmr, :error, %{errors: [%{message: "bang"}, %{message: "boom"}]}} + + assert :ok = Volt.HMR.clear_error("index.html") + assert_receive {:volt_hmr, :error, %{errors: [%{message: "bang"}]}} + + assert :ok = Volt.HMR.clear_error("index.html") + refute_receive {:volt_hmr, :error, _payload} end test "invalidate_file evicts dev compilation state" do diff --git a/test/volt/js/lint/config_test.exs b/test/volt/js/lint/config_test.exs index ab37a46..3ca4a52 100644 --- a/test/volt/js/lint/config_test.exs +++ b/test/volt/js/lint/config_test.exs @@ -3,24 +3,24 @@ defmodule Volt.JS.Lint.ConfigTest do alias Volt.JS.Lint.Config - test "merges all matching overrides in order and normalizes environment/global names" do + test "merges all matching overrides in order and resolves enabled environments" do config = Config.new( [ rules: %{"correctness" => :deny, "unicorn/no-null" => :deny}, - env: [:browser], - globals: %{shared: :readonly, retained: :readonly}, + env: ["browser"], + globals: %{"shared" => :readonly, "retained" => :readonly}, overrides: [ %{ files: ["scripts/**/*.{js,ts}", "build.js"], - env: %{browser: false, node: true}, + env: %{"browser" => false, "node" => true}, globals: %{"shared" => :writable}, rules: %{"unicorn/no-null" => :allow} }, [ files: ["scripts/release.*"], - env: [:mocha], - globals: %{shared: :off}, + env: ["mocha"], + globals: %{"shared" => :off}, rules: %{"unicorn/no-null" => :warn} ] ] @@ -30,9 +30,9 @@ defmodule Volt.JS.Lint.ConfigTest do options = Config.options(config, "assets/scripts/release.ts") assert options[:rules] == %{"correctness" => :deny, "unicorn/no-null" => :warn} - assert options[:env] == %{"browser" => false, "node" => true, "mocha" => true} + assert Enum.sort(options[:env]) == ["mocha", "node"] assert options[:globals] == %{"shared" => :off, "retained" => :readonly} - assert options[:plugins] == [:typescript] + assert options[:plugins] == ["typescript"] assert Config.options(config, "assets/build.js")[:rules]["unicorn/no-null"] == :allow assert Config.options(config, "assets/app.js")[:rules]["unicorn/no-null"] == :deny end @@ -64,11 +64,9 @@ defmodule Volt.JS.Lint.ConfigTest do test "matches already-discovered hidden files without expanding the filesystem" do config = - Config.new([overrides: [%{files: ["**/*.js"], env: [:node]}]], "hidden/.worktree/assets") + Config.new([overrides: [%{files: ["**/*.js"], env: ["node"]}]], "hidden/.worktree/assets") - assert Config.options(config, "hidden/.worktree/assets/.generated/build.js")[:env] == %{ - "node" => true - } + assert Config.options(config, "hidden/.worktree/assets/.generated/build.js")[:env] == ["node"] end test "requires scoped globs and rejects unsupported override settings" do @@ -77,7 +75,13 @@ defmodule Volt.JS.Lint.ConfigTest do end assert_raise ArgumentError, ~r/unsupported lint override keys/, fn -> - Config.new([overrides: [%{files: ["*.js"], plugins: [:node]}]], "assets") + Config.new([overrides: [%{files: ["*.js"], plugins: ["node"]}]], "assets") + end + + for config <- [[plugins: [:react]], [env: [:browser]], [globals: %{jQuery: :readonly}]] do + assert_raise ArgumentError, ~r/names must be strings/, fn -> + Config.new(config, "assets") + end end assert_raise GlobEx.CompileError, fn -> diff --git a/test/volt/js/transforms/imports_test.exs b/test/volt/js/transforms/imports_test.exs index 3634754..07ef1a4 100644 --- a/test/volt/js/transforms/imports_test.exs +++ b/test/volt/js/transforms/imports_test.exs @@ -138,7 +138,7 @@ defmodule Volt.JS.Transforms.ImportsTest do end test "raises on parse error" do - assert_raise RuntimeError, fn -> + assert_raise OXC.Error, ~r"bad.js:1:", fn -> Volt.JS.Transforms.Imports.rewrite!("const = ;", "bad.js", fn _ -> :keep end) end end diff --git a/test/volt/priv/vendor_test.exs b/test/volt/priv/vendor_test.exs new file mode 100644 index 0000000..d408ed3 --- /dev/null +++ b/test/volt/priv/vendor_test.exs @@ -0,0 +1,67 @@ +defmodule Volt.Priv.VendorTest do + use ExUnit.Case, async: true + + alias Volt.Priv.Vendor + + @moduletag :tmp_dir + + test "keeps the browser modules and declarations the sources reach", %{tmp_dir: tmp_dir} do + sources = Path.join(tmp_dir, "priv/ts") + node_modules = Path.join(tmp_dir, "install/node_modules") + + write!(sources, %{ + "app.ts" => """ + import { widget } from 'kit' + import 'kit/extra.js' + import { compile } from 'runtime-only' + import { local } from './local' + """, + "local.ts" => "export const local = 1\n" + }) + + write!(node_modules, %{ + "kit/package.json" => + Jason.encode!(%{ + name: "kit", + exports: %{ + "." => %{types: "./types/kit.d.ts", browser: "./kit.js", default: "./node/kit.js"}, + "./extra.js" => "./extra.js" + } + }), + "kit/LICENSE" => "MIT", + "kit/kit.js" => "import { helper } from './helper.js'\nexport const widget = helper\n", + "kit/helper.js" => "export const helper = 1\n", + "kit/node/kit.js" => "export const widget = 1\n", + "kit/extra.js" => "export {}\n", + "kit/extra.d.ts" => "export {}\n", + "kit/unused.js" => "export {}\n", + "kit/kit.js.map" => "{}", + "kit/types/kit.d.ts" => + "import type { Policy } from 'policy/lib/index.js'\nexport declare const widget: Policy\n", + "@types/policy/package.json" => Jason.encode!(%{name: "@types/policy"}), + "@types/policy/lib/index.d.ts" => "export type Policy = string\n", + "runtime-only/package.json" => Jason.encode!(%{name: "runtime-only", main: "index.js"}), + "runtime-only/index.js" => "export const compile = 1\n" + }) + + assert Vendor.reachable(sources, %{"kit" => "1.0.0"}, node_modules) == [ + "@types/policy/lib/index.d.ts", + "@types/policy/package.json", + "kit/LICENSE", + "kit/extra.d.ts", + "kit/extra.js", + "kit/helper.js", + "kit/kit.js", + "kit/package.json", + "kit/types/kit.d.ts" + ] + end + + defp write!(root, files) do + for {path, contents} <- files do + path = Path.join(root, path) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, contents) + end + end +end