diff --git a/.reach.exs b/.reach.exs index 3008122..0c8fdc1 100644 --- a/.reach.exs +++ b/.reach.exs @@ -22,6 +22,7 @@ adapter = [ ] orchestrator = [ + "Volt.Build", "Volt.Builder", "Volt.Builder.Collector", "Volt.Builder.Compiler", @@ -33,27 +34,36 @@ orchestrator = [ "Volt.JS.Vendor", "Volt.Pipeline", "Volt.Tailwind", + "Volt.Tailwind.Build", "Volt.Watcher", + "Volt.Dev.Session.Watcher", "Volt.Watcher.Ignore", "Volt.Watcher.Path" ] model = [ "Volt.Builder.BuildContext", + "Volt.Builder.Asset", "Volt.Builder.Bundle", "Volt.Builder.Collector.State", "Volt.Builder.Context", + "Volt.Builder.Compiled", "Volt.Builder.Dependencies", "Volt.Builder.ManifestEntry", "Volt.Builder.OutputContext", "Volt.Builder.OutputFile", "Volt.Builder.Result", + "Volt.Build.Result", + "Volt.Build.Layout", + "Volt.Config.Tailwind", "Volt.ChunkGraph.Chunk", "Volt.Config", "Volt.Config.*", + "Volt.Dev.Session.Tables", "Volt.DevServer.CacheEntry", "Volt.DevServer.Config", "Volt.HMR.Message", + "Volt.HMR.Channel", "Volt.JS.ImportExtractor.Result", "Volt.JS.PrebundleEntry.Export", "Volt.JS.PrebundleEntry.Import", @@ -61,10 +71,19 @@ model = [ "Volt.JS.TSConfig", "Volt.MIME", "Volt.Paths", - "Volt.Pipeline.Result.Hashes" + "Volt.Pipeline.Result.Hashes", + "Volt.Tailwind.CompileError", + "Volt.Tailwind.ResolveError", + "Volt.Tailwind.Context", + "Volt.Tailwind.Metadata", + "Volt.Tailwind.State", + "Volt.Tailwind.Compilation" ] logic = [ + "Volt.Builder.Artifact", + "Volt.Builder.CSS.Prepared", + "Volt.Builder.Plan", "Volt.Assets", "Volt.Assets.Query", "Volt.Builder.Naming", @@ -82,7 +101,7 @@ logic = [ "Volt.JS.Check", "Volt.JS.Extensions", "Volt.JS.Format", - "Volt.JS.Helpers", + "Volt.JS.Discovery", "Volt.JS.ImportExtractor", "Volt.JS.Package", "Volt.JS.Patch", @@ -115,8 +134,14 @@ infrastructure = [ "Volt.Application", "Volt.Builder.CSS", "Volt.Builder.Writer", + "Volt.Builder.Publication", "Volt.Cache", "Volt.Dev", + "Volt.Dev.Cleanup", + "Volt.Dev.Assets", + "Volt.Dev.Session.Call", + "Volt.Dev.Session.State", + "Volt.Dev.Session.Supervisor", "Volt.Dev.ConsoleForwarder", "Volt.Dev.ConsoleForwarder.Payload", "Volt.ETS", @@ -136,7 +161,11 @@ infrastructure = [ "Volt.JS.Runtime.Error", "Volt.JS.Runtime.Installer", "Volt.JS.Runtime.PackageSet", - "Volt.Tailwind.Loader" + "Volt.Tailwind.Artifact", + "Volt.Tailwind.Loader", + "Volt.Tailwind.Runtime", + "Volt.Tailwind.Supervisor", + "Volt.Tailwind.Worker" ] plugin = [ diff --git a/AGENTS.md b/AGENTS.md index a17e330..ea133ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,7 @@ # Project Guidelines +- Match primary Elixir module namespaces to their source paths. Nested data modules, protocol implementations, conditional definitions, and established acronym spellings are deliberate exceptions. +- Mirror source paths and namespaces in unit tests. Keep cross-module scenarios under the owning subsystem or integration directory; do not invent a production module merely to match a scenario test. +- Keep Mix tasks in nested `lib/mix/tasks/` paths as `.ex`, tests as `_test.exs`, and shared support as `.ex` under `test/support/`, compiled only in the test environment. `lib/volt/test/` contains shipped framework APIs, not project test support. + - Prefer parser/AST-backed solutions over hand-rolled regular expressions for source code, markup, and structured formats. Use existing parsers such as OXC for JavaScript/TypeScript and Floki for HTML-like markup when available. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c677f2..fd1d68a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ ## Unreleased +### Breaking changes + +- Rename `Volt.JS.Helpers` to `Volt.JS.Discovery`, with `files/0,1` and `format_files/0` replacing `discover_files/0,1` and `discover_format_files/0`. + +### Added + +- Add `Volt.build/1` as the library API for complete frontend builds with one typed result and merged manifest for scripts, styles, chunks, and emitted assets. +- Isolate Tailwind scanner and CSS state by profile and stylesheet root while sharing one supervised compiler runtime. +- Allow Tailwind roots to configure their logical output name, development URL, and source globs. + +### Changed + +- Require QuickBEAM 0.11.1 or later in the 0.11 series for release-optimized, baseline-CPU precompiled runtimes and the Mint 1.10 security dependency floor. +- Write one production manifest at the configured asset output root instead of separate JavaScript and Tailwind manifests. +- Make `mix volt.build` a thin adapter over `Volt.build/1`. +- Emit Tailwind development artifacts atomically and skip style HMR broadcasts when generated CSS is unchanged. +- Normalize in-memory build manifests and emitted asset metadata to typed structs before JSON serialization. + +### Fixed + +- Allow JavaScript formatting and checks when the bundle format is configured as `:esm`, without treating the format atom as formatter options ([#36](https://github.com/elixir-volt/volt/pull/36), fixes [#34](https://github.com/elixir-volt/volt/issues/34)). +- Handle filesystem watcher events for the watched root itself when normalizing macOS path aliases. +- Apply Vue scoped styles without capturing component-module bindings such as `Object`. + +### Security + +- Require Igniter 0.8.4 or later to prevent terminal escape injection through package metadata in installer confirmation prompts (CVE-2026-82584). +- Require Bandit 1.12.5 or later in the Phoenix examples to address HTTP/2 header validation and connection-window starvation (CVE-2026-75484, CVE-2026-74836). + +- Update Phoenix example locks to patched Phoenix and LiveView releases for channel-join and long-poll denial of service, presence-client crashes, link scheme validation, and redirect validation. + ## 0.17.11 - 2026-09-04 ### Added diff --git a/README.md b/README.md index b821e95..cd7f94c 100644 --- a/README.md +++ b/README.md @@ -88,13 +88,11 @@ The `Volt.DevServer` Plug starts its supervised file watcher on the first develo ``` $ mix volt.build -Building Tailwind CSS... - app-1a2b3c4d.css 23.9 KB -Built Tailwind in 43ms -Building "assets/js/app.ts"... +Building ["assets/js/app.ts"]... app-5e6f7a8b.js 128.4 KB (gzip: 38.2 KB) + app-1a2b3c4d.css 23.9 KB manifest.json 2 entries -Built in 15ms +Built in 58ms ``` Tree-shaking, minification, code splitting, configurable env prefixes and asset URL prefixes, source maps, content-hashed JavaScript/CSS/assets, and manifest output. `Volt.Preload.tags/2` can generate modulepreload tags from the manifest, and the build is ready for `mix phx.digest`. diff --git a/examples/react/mix.exs b/examples/react/mix.exs index fdcabe5..b2afdf7 100644 --- a/examples/react/mix.exs +++ b/examples/react/mix.exs @@ -35,7 +35,7 @@ defmodule ReactExample.MixProject do {:telemetry_poller, "~> 1.0"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, - {:bandit, "~> 1.5"}, + {:bandit, ">= 1.12.5 and < 2.0.0"}, {:volt, path: "../..", override: true} ] end diff --git a/examples/react/mix.lock b/examples/react/mix.lock index e37aeb5..1e04617 100644 --- a/examples/react/mix.lock +++ b/examples/react/mix.lock @@ -1,42 +1,45 @@ %{ - "bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"}, - "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"}, + "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, + "castore": {:hex, :castore, "1.0.21", "0a0e8330dc267a40a3b7ad86d39302764bb71758172904e6a59d5ad6443ce307", [:mix], [], "hexpm", "e42e22723e25dbd46876d056a03f685513d6e98f6b5e555dc551321decd76c5c"}, "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.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [: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", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, + "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"}, "floki": {:hex, :floki, "0.38.1", "f002ccac94b3bcb21d40d9b34cc2cc9fd88a8311879120330075b5dde657ebee", [:mix], [], "hexpm", "e744bf0db7ee34b2c8b62767f04071107af0516a81144b9a2f73fe0494200e5b"}, - "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, - "hex_solver": {:hex, :hex_solver, "0.2.3", "0d2ee20fbceb251d573f03ef34852e325529c2874ab66d1b576384021318996c", [:mix], [], "hexpm", "9daeae2ea6b8ad3dc7f51a10484f6bc1d0f705c31063383830a7167ab93b887b"}, - "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "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"}, "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_spec": {:hex, :json_spec, "1.2.0", "c67f73cdc4166e955b2f1095d3f44dd78c197337038f1a5301fb526b6c87ab04", [:mix], [], "hexpm", "3639961babad7d6bc45f3a2d96a49c18ec70614761bb84ce58b64e9fad9354de"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [: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", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, - "mint_web_socket": {:hex, :mint_web_socket, "1.0.5", "60354efeb49b1eccf95dfb75f55b08d692e211970fe735a5eb3188b328be2a90", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "04b35663448fc758f3356cce4d6ac067ca418bbafe6972a3805df984b5f12e61"}, + "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_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.4", "a8cc2e760a30789e6b590257cfae9384fd1de470dab8ef62a7b35859587f34b7", [: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", "ee265d380c19dbb9d1bba50458e1938711569a195423e6cfbf9ef7c3b8d7658a"}, + "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.15.1", "d4b1770f26cd80b84ac592a54db38c50af92c98ce480be2ae1badbc0eccd614e", [: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", "1008f80d04b961f5f262a0aec99781f3a73e5c4c7fc969f4336ca96f653c7d58"}, - "oxide_ex": {:hex, :oxide_ex, "0.2.1", "ff0e1c03f58c151ca87e3b11847801a751853894fd926910f459ef5918cc655a", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "25bebc0d30362dead560e815a1380c266d018924a71a548ea2b1582f8dcd77bb"}, - "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [: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, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, + "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"}, + "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"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.33", "8350c8bebabfa318c9afb0970eacced9174cd72603c9be2240e99d550fcc2eed", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2030f7987f641a269634e021bcde9373e5dea993f9373e628a44da86b2330b6c"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, "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.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [: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", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, - "quickbeam": {:hex, :quickbeam, "0.10.15", "8622e090ba8476b4a7c008ddb75c741e770f45e64f9fc30c43b02694213a8605", [:mix], [{:jason, "~> 1.4", [hex: :jason, 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.4", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.15.0", [hex: :oxc, repo: "hexpm", optional: false]}, {:zigler, "~> 0.15.2", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.4", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "2da3750f1e99ae1d97b6e99a2b8ea86ba48c49c568d8f711d65d342430dcfb86"}, - "req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [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", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"}, + "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"}, + "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"}, "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.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, - "vize": {:hex, :vize, "0.11.1", "eda811711b1663e548936b44ede72eac9bad1efdc1ff24be7ae86ea3b03541d2", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9d53506c28c8c2a1947322d23fcb95c20e8c4d9ddb2e33b10c794e497ee55c5a"}, + "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"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [: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", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, - "zigler_precompiled": {:hex, :zigler_precompiled, "0.1.4", "20a166584b94d860a983ef39c93cb0a9ce40c95c48555fc3cb6532c405558410", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:zigler, "~> 0.13", [hex: :zigler, repo: "hexpm", optional: true]}], "hexpm", "9cd6998a8ddf2f03ded1b6ebc9acb9bfa5e23b614f047583c76c01bf66c4640e"}, + "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/solid/mix.exs b/examples/solid/mix.exs index 162b121..1ce7292 100644 --- a/examples/solid/mix.exs +++ b/examples/solid/mix.exs @@ -35,7 +35,7 @@ defmodule SolidExample.MixProject do {:telemetry_poller, "~> 1.0"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, - {:bandit, "~> 1.5"}, + {:bandit, ">= 1.12.5 and < 2.0.0"}, {:volt, path: "../..", override: true} ] end diff --git a/examples/solid/mix.lock b/examples/solid/mix.lock index e37aeb5..1e04617 100644 --- a/examples/solid/mix.lock +++ b/examples/solid/mix.lock @@ -1,42 +1,45 @@ %{ - "bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"}, - "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"}, + "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, + "castore": {:hex, :castore, "1.0.21", "0a0e8330dc267a40a3b7ad86d39302764bb71758172904e6a59d5ad6443ce307", [:mix], [], "hexpm", "e42e22723e25dbd46876d056a03f685513d6e98f6b5e555dc551321decd76c5c"}, "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.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [: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", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, + "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"}, "floki": {:hex, :floki, "0.38.1", "f002ccac94b3bcb21d40d9b34cc2cc9fd88a8311879120330075b5dde657ebee", [:mix], [], "hexpm", "e744bf0db7ee34b2c8b62767f04071107af0516a81144b9a2f73fe0494200e5b"}, - "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, - "hex_solver": {:hex, :hex_solver, "0.2.3", "0d2ee20fbceb251d573f03ef34852e325529c2874ab66d1b576384021318996c", [:mix], [], "hexpm", "9daeae2ea6b8ad3dc7f51a10484f6bc1d0f705c31063383830a7167ab93b887b"}, - "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "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"}, "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_spec": {:hex, :json_spec, "1.2.0", "c67f73cdc4166e955b2f1095d3f44dd78c197337038f1a5301fb526b6c87ab04", [:mix], [], "hexpm", "3639961babad7d6bc45f3a2d96a49c18ec70614761bb84ce58b64e9fad9354de"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [: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", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, - "mint_web_socket": {:hex, :mint_web_socket, "1.0.5", "60354efeb49b1eccf95dfb75f55b08d692e211970fe735a5eb3188b328be2a90", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "04b35663448fc758f3356cce4d6ac067ca418bbafe6972a3805df984b5f12e61"}, + "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_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.4", "a8cc2e760a30789e6b590257cfae9384fd1de470dab8ef62a7b35859587f34b7", [: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", "ee265d380c19dbb9d1bba50458e1938711569a195423e6cfbf9ef7c3b8d7658a"}, + "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.15.1", "d4b1770f26cd80b84ac592a54db38c50af92c98ce480be2ae1badbc0eccd614e", [: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", "1008f80d04b961f5f262a0aec99781f3a73e5c4c7fc969f4336ca96f653c7d58"}, - "oxide_ex": {:hex, :oxide_ex, "0.2.1", "ff0e1c03f58c151ca87e3b11847801a751853894fd926910f459ef5918cc655a", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "25bebc0d30362dead560e815a1380c266d018924a71a548ea2b1582f8dcd77bb"}, - "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [: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, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, + "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"}, + "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"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.33", "8350c8bebabfa318c9afb0970eacced9174cd72603c9be2240e99d550fcc2eed", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2030f7987f641a269634e021bcde9373e5dea993f9373e628a44da86b2330b6c"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, "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.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [: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", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, - "quickbeam": {:hex, :quickbeam, "0.10.15", "8622e090ba8476b4a7c008ddb75c741e770f45e64f9fc30c43b02694213a8605", [:mix], [{:jason, "~> 1.4", [hex: :jason, 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.4", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.15.0", [hex: :oxc, repo: "hexpm", optional: false]}, {:zigler, "~> 0.15.2", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.4", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "2da3750f1e99ae1d97b6e99a2b8ea86ba48c49c568d8f711d65d342430dcfb86"}, - "req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [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", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"}, + "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"}, + "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"}, "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.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, - "vize": {:hex, :vize, "0.11.1", "eda811711b1663e548936b44ede72eac9bad1efdc1ff24be7ae86ea3b03541d2", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9d53506c28c8c2a1947322d23fcb95c20e8c4d9ddb2e33b10c794e497ee55c5a"}, + "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"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [: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", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, - "zigler_precompiled": {:hex, :zigler_precompiled, "0.1.4", "20a166584b94d860a983ef39c93cb0a9ce40c95c48555fc3cb6532c405558410", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:zigler, "~> 0.13", [hex: :zigler, repo: "hexpm", optional: true]}], "hexpm", "9cd6998a8ddf2f03ded1b6ebc9acb9bfa5e23b614f047583c76c01bf66c4640e"}, + "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/svelte/mix.exs b/examples/svelte/mix.exs index 70583ea..99412b2 100644 --- a/examples/svelte/mix.exs +++ b/examples/svelte/mix.exs @@ -35,7 +35,7 @@ defmodule SvelteExample.MixProject do {:telemetry_poller, "~> 1.0"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, - {:bandit, "~> 1.5"}, + {:bandit, ">= 1.12.5 and < 2.0.0"}, {:volt, path: "../..", override: true} ] end diff --git a/examples/svelte/mix.lock b/examples/svelte/mix.lock index e37aeb5..1e04617 100644 --- a/examples/svelte/mix.lock +++ b/examples/svelte/mix.lock @@ -1,42 +1,45 @@ %{ - "bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"}, - "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"}, + "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, + "castore": {:hex, :castore, "1.0.21", "0a0e8330dc267a40a3b7ad86d39302764bb71758172904e6a59d5ad6443ce307", [:mix], [], "hexpm", "e42e22723e25dbd46876d056a03f685513d6e98f6b5e555dc551321decd76c5c"}, "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.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [: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", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, + "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"}, "floki": {:hex, :floki, "0.38.1", "f002ccac94b3bcb21d40d9b34cc2cc9fd88a8311879120330075b5dde657ebee", [:mix], [], "hexpm", "e744bf0db7ee34b2c8b62767f04071107af0516a81144b9a2f73fe0494200e5b"}, - "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, - "hex_solver": {:hex, :hex_solver, "0.2.3", "0d2ee20fbceb251d573f03ef34852e325529c2874ab66d1b576384021318996c", [:mix], [], "hexpm", "9daeae2ea6b8ad3dc7f51a10484f6bc1d0f705c31063383830a7167ab93b887b"}, - "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "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"}, "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_spec": {:hex, :json_spec, "1.2.0", "c67f73cdc4166e955b2f1095d3f44dd78c197337038f1a5301fb526b6c87ab04", [:mix], [], "hexpm", "3639961babad7d6bc45f3a2d96a49c18ec70614761bb84ce58b64e9fad9354de"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [: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", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, - "mint_web_socket": {:hex, :mint_web_socket, "1.0.5", "60354efeb49b1eccf95dfb75f55b08d692e211970fe735a5eb3188b328be2a90", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "04b35663448fc758f3356cce4d6ac067ca418bbafe6972a3805df984b5f12e61"}, + "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_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.4", "a8cc2e760a30789e6b590257cfae9384fd1de470dab8ef62a7b35859587f34b7", [: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", "ee265d380c19dbb9d1bba50458e1938711569a195423e6cfbf9ef7c3b8d7658a"}, + "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.15.1", "d4b1770f26cd80b84ac592a54db38c50af92c98ce480be2ae1badbc0eccd614e", [: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", "1008f80d04b961f5f262a0aec99781f3a73e5c4c7fc969f4336ca96f653c7d58"}, - "oxide_ex": {:hex, :oxide_ex, "0.2.1", "ff0e1c03f58c151ca87e3b11847801a751853894fd926910f459ef5918cc655a", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "25bebc0d30362dead560e815a1380c266d018924a71a548ea2b1582f8dcd77bb"}, - "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [: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, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, + "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"}, + "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"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.33", "8350c8bebabfa318c9afb0970eacced9174cd72603c9be2240e99d550fcc2eed", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2030f7987f641a269634e021bcde9373e5dea993f9373e628a44da86b2330b6c"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, "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.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [: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", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, - "quickbeam": {:hex, :quickbeam, "0.10.15", "8622e090ba8476b4a7c008ddb75c741e770f45e64f9fc30c43b02694213a8605", [:mix], [{:jason, "~> 1.4", [hex: :jason, 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.4", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.15.0", [hex: :oxc, repo: "hexpm", optional: false]}, {:zigler, "~> 0.15.2", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.4", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "2da3750f1e99ae1d97b6e99a2b8ea86ba48c49c568d8f711d65d342430dcfb86"}, - "req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [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", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"}, + "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"}, + "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"}, "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.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, - "vize": {:hex, :vize, "0.11.1", "eda811711b1663e548936b44ede72eac9bad1efdc1ff24be7ae86ea3b03541d2", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9d53506c28c8c2a1947322d23fcb95c20e8c4d9ddb2e33b10c794e497ee55c5a"}, + "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"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [: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", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, - "zigler_precompiled": {:hex, :zigler_precompiled, "0.1.4", "20a166584b94d860a983ef39c93cb0a9ce40c95c48555fc3cb6532c405558410", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:zigler, "~> 0.13", [hex: :zigler, repo: "hexpm", optional: true]}], "hexpm", "9cd6998a8ddf2f03ded1b6ebc9acb9bfa5e23b614f047583c76c01bf66c4640e"}, + "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/vanilla/mix.exs b/examples/vanilla/mix.exs index 5548284..4c47cac 100644 --- a/examples/vanilla/mix.exs +++ b/examples/vanilla/mix.exs @@ -35,7 +35,7 @@ defmodule VanillaExample.MixProject do {:telemetry_poller, "~> 1.0"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, - {:bandit, "~> 1.5"}, + {:bandit, ">= 1.12.5 and < 2.0.0"}, {:volt, path: "../..", override: true} ] end diff --git a/examples/vanilla/mix.lock b/examples/vanilla/mix.lock index f85d5b5..9c4872f 100644 --- a/examples/vanilla/mix.lock +++ b/examples/vanilla/mix.lock @@ -1,43 +1,45 @@ %{ - "bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"}, - "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"}, + "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, + "castore": {:hex, :castore, "1.0.21", "0a0e8330dc267a40a3b7ad86d39302764bb71758172904e6a59d5ad6443ce307", [:mix], [], "hexpm", "e42e22723e25dbd46876d056a03f685513d6e98f6b5e555dc551321decd76c5c"}, "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"}, "floki": {:hex, :floki, "0.38.1", "f002ccac94b3bcb21d40d9b34cc2cc9fd88a8311879120330075b5dde657ebee", [:mix], [], "hexpm", "e744bf0db7ee34b2c8b62767f04071107af0516a81144b9a2f73fe0494200e5b"}, - "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, - "hex_solver": {:hex, :hex_solver, "0.2.3", "0d2ee20fbceb251d573f03ef34852e325529c2874ab66d1b576384021318996c", [:mix], [], "hexpm", "9daeae2ea6b8ad3dc7f51a10484f6bc1d0f705c31063383830a7167ab93b887b"}, + "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"}, "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.1.6", "50493aa3daf952f9c71a828112e25d7e6dde9622d33571981ba485892b5bbb7f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cda1825c2e2cbb7d8b90833ee72867aee1a3802772bc2ff1e12b61f2801554ec"}, + "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_spec": {:hex, :json_spec, "1.2.0", "c67f73cdc4166e955b2f1095d3f44dd78c197337038f1a5301fb526b6c87ab04", [:mix], [], "hexpm", "3639961babad7d6bc45f3a2d96a49c18ec70614761bb84ce58b64e9fad9354de"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.9.1", "3bc120b743ed2e99ad920910f2613e9faebabb2257731b0e2ea4d8ccd9eceede", [: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", "831101bd560b086316fab5f7adb21a4f3455717d8e4bc8368b052e09aa9163e0"}, - "mint_web_socket": {:hex, :mint_web_socket, "1.0.5", "60354efeb49b1eccf95dfb75f55b08d692e211970fe735a5eb3188b328be2a90", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "04b35663448fc758f3356cce4d6ac067ca418bbafe6972a3805df984b5f12e61"}, + "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_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.4", "a8cc2e760a30789e6b590257cfae9384fd1de470dab8ef62a7b35859587f34b7", [: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", "ee265d380c19dbb9d1bba50458e1938711569a195423e6cfbf9ef7c3b8d7658a"}, + "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.1", "5027b48bd667d615b7cafba88dd68ebf018dd9cde6a68274643dfd3dfd6306e1", [: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", "e4f0d40a03d373244032c24dc27da407dceb4e1a096f6fb76f207436492f6c62"}, - "oxide_ex": {:hex, :oxide_ex, "0.2.1", "ff0e1c03f58c151ca87e3b11847801a751853894fd926910f459ef5918cc655a", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "25bebc0d30362dead560e815a1380c266d018924a71a548ea2b1582f8dcd77bb"}, - "phoenix": {:hex, :phoenix, "1.8.9", "a63ed0962ed5b903b146dab0ae8eb8387fe478f8171a5e26d56a165f35996fe1", [: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, "~> 1.2 or ~> 2.0", [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", "3477e2dd5a4f61820341169031bdfe21275f659923bea9c5c0ea2aa1c3fcc046"}, + "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"}, + "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"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.6", "b2b50c51993b068fe2a29eda45ca4fe67bf74f146343468485686a253c65a8da", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5dc549a78dab94e80a340c760090e9e7bab16ac47841e34f4fff5819e02cbf35"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.11", "1c83fdfd007e8d554076baee00ee6f55bb9c87ad9439f33ee520e47ae4a983bb", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c4087267cbd4dc4ed7ac36512a97830af8e9c857ca7cb22557f8640fc5f9d306"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, "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.2", "adbee2441232412e37fbb357fd5e4cd533fdd253b29f2e1992262b0f1fb01462", [: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", "b16baf55877d60891002ffc1ce0b3ff7d6f30a38a23e02e4d4293c4ac266f136"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, - "quickbeam": {:hex, :quickbeam, "0.10.18", "b6e655af25b4aa56464a1f273f52948e95fed59bb15349a2f0d22d02b578edb0", [:mix], [{:jason, "~> 1.4", [hex: :jason, 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.4", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.17.1", [hex: :oxc, repo: "hexpm", optional: false]}, {:zigler, "~> 0.15.2", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.4", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "71439528a9be63bfd0b052306952c96f3e99fc7bef03dea7e8859d61d84a43d5"}, - "req": {:hex, :req, "0.6.2", "b9b2024f35bcf60a92cc8cad2eaaf9d4e7aace463ff74be1afe5986830184413", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, 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", "cc9cd30a2ddd04989929b887178e1610c940456d962c6c3a52df6146d2eef9bf"}, + "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"}, + "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"}, "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"}, - "vize": {:hex, :vize, "0.13.1", "e125361d93a18b0362e45f2724cdb25aeef1b78d9f379c89841d1fc1f0cd24a6", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "56cb56fd6806a084aeb1f52a18948160eac693c37f032e724d2b8b955be4d206"}, + "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"}, "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.4", "20a166584b94d860a983ef39c93cb0a9ce40c95c48555fc3cb6532c405558410", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:zigler, "~> 0.13", [hex: :zigler, repo: "hexpm", optional: true]}], "hexpm", "9cd6998a8ddf2f03ded1b6ebc9acb9bfa5e23b614f047583c76c01bf66c4640e"}, + "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/mix.exs b/examples/vue/mix.exs index 226ef67..267fd7a 100644 --- a/examples/vue/mix.exs +++ b/examples/vue/mix.exs @@ -35,7 +35,7 @@ defmodule VueExample.MixProject do {:telemetry_poller, "~> 1.0"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, - {:bandit, "~> 1.5"}, + {:bandit, ">= 1.12.5 and < 2.0.0"}, {:volt, path: "../..", override: true} ] end diff --git a/examples/vue/mix.lock b/examples/vue/mix.lock index e37aeb5..1e04617 100644 --- a/examples/vue/mix.lock +++ b/examples/vue/mix.lock @@ -1,42 +1,45 @@ %{ - "bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"}, - "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"}, + "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, + "castore": {:hex, :castore, "1.0.21", "0a0e8330dc267a40a3b7ad86d39302764bb71758172904e6a59d5ad6443ce307", [:mix], [], "hexpm", "e42e22723e25dbd46876d056a03f685513d6e98f6b5e555dc551321decd76c5c"}, "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.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [: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", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, + "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"}, "floki": {:hex, :floki, "0.38.1", "f002ccac94b3bcb21d40d9b34cc2cc9fd88a8311879120330075b5dde657ebee", [:mix], [], "hexpm", "e744bf0db7ee34b2c8b62767f04071107af0516a81144b9a2f73fe0494200e5b"}, - "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, - "hex_solver": {:hex, :hex_solver, "0.2.3", "0d2ee20fbceb251d573f03ef34852e325529c2874ab66d1b576384021318996c", [:mix], [], "hexpm", "9daeae2ea6b8ad3dc7f51a10484f6bc1d0f705c31063383830a7167ab93b887b"}, - "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "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"}, "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_spec": {:hex, :json_spec, "1.2.0", "c67f73cdc4166e955b2f1095d3f44dd78c197337038f1a5301fb526b6c87ab04", [:mix], [], "hexpm", "3639961babad7d6bc45f3a2d96a49c18ec70614761bb84ce58b64e9fad9354de"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [: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", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, - "mint_web_socket": {:hex, :mint_web_socket, "1.0.5", "60354efeb49b1eccf95dfb75f55b08d692e211970fe735a5eb3188b328be2a90", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "04b35663448fc758f3356cce4d6ac067ca418bbafe6972a3805df984b5f12e61"}, + "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_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.4", "a8cc2e760a30789e6b590257cfae9384fd1de470dab8ef62a7b35859587f34b7", [: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", "ee265d380c19dbb9d1bba50458e1938711569a195423e6cfbf9ef7c3b8d7658a"}, + "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.15.1", "d4b1770f26cd80b84ac592a54db38c50af92c98ce480be2ae1badbc0eccd614e", [: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", "1008f80d04b961f5f262a0aec99781f3a73e5c4c7fc969f4336ca96f653c7d58"}, - "oxide_ex": {:hex, :oxide_ex, "0.2.1", "ff0e1c03f58c151ca87e3b11847801a751853894fd926910f459ef5918cc655a", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "25bebc0d30362dead560e815a1380c266d018924a71a548ea2b1582f8dcd77bb"}, - "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [: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, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, + "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"}, + "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"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.33", "8350c8bebabfa318c9afb0970eacced9174cd72603c9be2240e99d550fcc2eed", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2030f7987f641a269634e021bcde9373e5dea993f9373e628a44da86b2330b6c"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"}, "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.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [: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", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, - "quickbeam": {:hex, :quickbeam, "0.10.15", "8622e090ba8476b4a7c008ddb75c741e770f45e64f9fc30c43b02694213a8605", [:mix], [{:jason, "~> 1.4", [hex: :jason, 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.4", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.15.0", [hex: :oxc, repo: "hexpm", optional: false]}, {:zigler, "~> 0.15.2", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.4", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "2da3750f1e99ae1d97b6e99a2b8ea86ba48c49c568d8f711d65d342430dcfb86"}, - "req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [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", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"}, + "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"}, + "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"}, "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.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, - "vize": {:hex, :vize, "0.11.1", "eda811711b1663e548936b44ede72eac9bad1efdc1ff24be7ae86ea3b03541d2", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9d53506c28c8c2a1947322d23fcb95c20e8c4d9ddb2e33b10c794e497ee55c5a"}, + "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"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [: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", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, - "zigler_precompiled": {:hex, :zigler_precompiled, "0.1.4", "20a166584b94d860a983ef39c93cb0a9ce40c95c48555fc3cb6532c405558410", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:zigler, "~> 0.13", [hex: :zigler, repo: "hexpm", optional: true]}], "hexpm", "9cd6998a8ddf2f03ded1b6ebc9acb9bfa5e23b614f047583c76c01bf66c4640e"}, + "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/guides/deployment/production-builds.md b/guides/deployment/production-builds.md index 4c37a6d..a9325da 100644 --- a/guides/deployment/production-builds.md +++ b/guides/deployment/production-builds.md @@ -5,17 +5,56 @@ mix volt.build ``` ```text -Building Tailwind CSS... - app-1a2b3c4d.css 23.9 KB -Built Tailwind in 43ms -Building "assets/js/app.ts"... +Building ["assets/js/app.ts"]... app-5e6f7a8b.js 128.4 KB + app-1a2b3c4d.css 23.9 KB manifest.json 2 entries -Built in 15ms +Built in 58ms ``` Reads configuration from `config :volt`. CLI flags override config values. +## Publication guarantees + +Volt prepares and validates compiled assets and public files before publication. +It stages file contents beside the destination, installs assets, then installs the +manifest last. Compilation, validation, and staging failures leave previous output +untouched. Ordinary failures clean up staging files. + +Publication is **not a whole-directory transaction**. An installation failure may +leave some new assets in place; unhashed URLs can then expose a mixed generation. +An error is returned and later files are not installed. Existing unrelated files +are preserved. Replacement behavior depends on the host filesystem; crash durability +and cross-platform atomic replacement are not guaranteed. + +Publications to the same expanded destination are serialized within one BEAM node. +This does not coordinate separate OS processes, symlink aliases, overlapping output +roots, or build preparation order. Deployments needing an atomic site switch should +publish to a fresh release directory and switch releases at the deployment layer. + +## Publication root and asset directory + +For `Volt.build/1`, `outdir` is the publication root: public files and the manifest +are placed there. `assets_dir` is a relative subdirectory for compiled output +(default: `""`, preserving Volt's existing output locations). It applies equally +to flat and split layouts. For example, `outdir: "dist", assets_dir: "assets"` +places public files in `dist/` and compiled output under `dist/assets/`. +`asset_url_prefix` identifies the publication root's browser URL; the asset directory +and layout suffixes are appended when generating URLs. + +The released lower-level `Volt.Builder.build/1` retains its asset-directory semantics: +its `outdir` contains compiled files and public files go to the parent. This is a +boundary adapter, not another independently configurable output root. + +## Output layout + +`Volt.build/1` accepts `output_layout: :split` (the default) or `:flat`. +Split builds put module-graph output in `js/` and Tailwind output in `css/`. +Flat builds emit both into the asset directory (`outdir` when `assets_dir` is empty). +Both layouts write one root `manifest.json`; emitted file paths and asset URLs +are calculated for the selected layout before writing, never relocated afterward. +Conflicting manifest identities fail the build instead of silently replacing entries. + ## What production builds do Production builds run the same framework/plugin compilation pipeline as the dev server, then apply build-only graph and output steps: @@ -25,7 +64,7 @@ Production builds run the same framework/plugin compilation pipeline as the dev - rewrites relative CSS `url(...)` asset references through the asset pipeline - copies JavaScript- and CSS-referenced assets with content hashes - tree-shakes, minifies, and optionally code-splits JavaScript -- writes a manifest that Phoenix can use for digested asset paths and chunk preload metadata +- writes one manifest at `priv/static/assets/manifest.json` for scripts, styles, emitted assets, and chunk preload metadata - optionally copies a Vite-style public directory to the static root without transforming files ## Public files in Phoenix apps @@ -102,7 +141,7 @@ Or per-build: `mix volt.build --external phoenix --external phoenix_html` For code-split builds, the production manifest records static imports, dynamic imports, chunk-local CSS, and emitted assets. Use `Volt.Preload.tags/2` in your layout to preload the entry and its static chunk dependencies: ```heex -<%= Volt.Preload.tags("priv/static/assets/js/manifest.json", "/assets/js", entry: "app.js") %> +<%= Volt.Preload.tags("priv/static/assets/manifest.json", "/assets", entry: "app.js") %> ``` Runtime dynamic imports are rewritten through Volt's preload helper when the async chunk has dependency chunks or CSS. The helper preloads those files before executing `import()`, avoiding extra round trips while keeping async chunks lazy. diff --git a/guides/features/hmr.md b/guides/features/hmr.md index b94cbd7..fe5bc66 100644 --- a/guides/features/hmr.md +++ b/guides/features/hmr.md @@ -2,6 +2,33 @@ The file watcher monitors your asset and template directories and pushes updates to the browser over a WebSocket. +## Managed session ownership + +Choose one watcher owner. A watched `Volt.DevServer` starts a managed session; +`mix volt.dev` also owns a managed session and preserves its file-backed CSS sink. +Do not enable automatic Plug watching alongside a CLI watcher with different options. +To attach a Plug to a CLI-owned session, use `watch: false` and the matching identity: + +```elixir +session = Volt.Dev.session_identity(root: "assets", id: :default) +plug Volt.DevServer, root: "assets", watch: false, session: session +``` + +Use the named profile as `id` when the CLI runs with a profile. Session identity is +independent of stylesheet URL. Put Volt's development Plug **before `Plug.Static`** +for its owned asset URLs; an earlier static plug can halt with stale generated CSS. +Configure the same Tailwind input/URL for the host and CLI. Conflicting managed +start configurations are rejected rather than merged. Requests encountering a +session disappearing during lookup return a retryable 503. + +## Stylesheet dependency assets + +Relative image and font URLs resolved from project stylesheets can refer to files +outside the asset root, including package assets. Volt serves these through +session-scoped `/@volt/assets/` URLs. Only files resolved by the stylesheet pipeline +are registered; the endpoint does not accept filesystem paths. Registrations end +with the session's state generation. + ## What Gets Updated | File type | Action | diff --git a/guides/features/tailwind.md b/guides/features/tailwind.md index d4cb881..6a6ef0a 100644 --- a/guides/features/tailwind.md +++ b/guides/features/tailwind.md @@ -20,6 +20,20 @@ config :volt, - `css` — path to your Tailwind input CSS file (with `@import "tailwindcss"`) - `sources` — list of `%{base, pattern}` maps defining where to scan for class names +## Build Lifecycle + +`Volt.build/1` is the public API for complete frontend builds. It composes the configured Tailwind root with the ordinary JavaScript/CSS module graph, returns a typed `%Volt.Build.Result{}`, and writes one merged manifest at the configured asset output root. `mix volt.build` is a CLI adapter around that API. + +```elixir +{:ok, result} = Volt.build() + +result.styles +result.assets +result.manifest +``` + +Tailwind output is isolated per profile and CSS root. Root workers retain independent Oxide scanners and generated CSS state while sharing one supervised QuickBEAM compiler runtime. + ## Plugins and Config Files Tailwind `@plugin` and `@config` directives are resolved and bundled automatically: diff --git a/lib/mix/tasks/volt.build.ex b/lib/mix/tasks/volt/build.ex similarity index 67% rename from lib/mix/tasks/volt.build.ex rename to lib/mix/tasks/volt/build.ex index 2d6854f..be1f616 100644 --- a/lib/mix/tasks/volt.build.ex +++ b/lib/mix/tasks/volt/build.ex @@ -13,6 +13,8 @@ defmodule Mix.Tasks.Volt.Build do ## Options * `--entry` — entry file (repeatable, default from config or `"assets/js/app.ts"`) + * `--assets-dir` — relative compiled-asset directory inside the output root + * `--output-layout` — `flat` or `split` * `--outdir` — output directory (default: `"priv/static/assets"`) * `--public-dir` — optional Vite-style public directory copied to the static root as-is * `--asset-url-prefix` — public URL prefix for production asset references (default: `"/assets"`) @@ -44,6 +46,8 @@ defmodule Mix.Tasks.Volt.Build do strict: [ entry: [:string, :keep], outdir: :string, + assets_dir: :string, + output_layout: :string, public_dir: :string, asset_url_prefix: :string, target: :string, @@ -74,13 +78,17 @@ defmodule Mix.Tasks.Volt.Build do outdir = Keyword.get(parsed, :outdir) || to_string(config.outdir) minify = Keyword.get(parsed, :minify, config.minify) - tailwind? = - Keyword.get(parsed, :tailwind) || - (tailwind_config != [] and Keyword.get(parsed, :tailwind, true)) + tailwind = + case Keyword.fetch(parsed, :tailwind) do + {:ok, false} -> + false - if tailwind? do - build_tailwind(parsed, tailwind_config, outdir, minify, config.hash, config.root) - end + {:ok, true} -> + if(tailwind_config == [], do: true, else: tailwind_config) + + :error -> + if parsed[:tailwind_css] && tailwind_config == [], do: true, else: tailwind_config + end entries = case cli_entries do @@ -102,7 +110,9 @@ defmodule Mix.Tasks.Volt.Build do opts = [ entry: if(length(entries) == 1, do: hd(entries), else: entries), - outdir: Path.join(outdir, "js"), + outdir: outdir, + assets_dir: Keyword.get(parsed, :assets_dir, config.assets_dir), + output_layout: parse_layout(Keyword.get(parsed, :output_layout), config.output_layout), public_dir: Keyword.get(parsed, :public_dir) || config.public_dir, asset_url_prefix: Keyword.get(parsed, :asset_url_prefix) || config.asset_url_prefix, target: Keyword.get(parsed, :target) || to_string(config.target), @@ -122,23 +132,34 @@ defmodule Mix.Tasks.Volt.Build do import_source: config.import_source, module_types: config.module_types, root: config.root, - name: parsed[:name] + name: parsed[:name], + profile: profile, + tailwind: tailwind, + tailwind_css: parsed[:tailwind_css], + tailwind_sources: tailwind_sources(parsed, tailwind_config) ] opts = if opts[:name], do: opts, else: Keyword.delete(opts, :name) - build_js(opts) + build_all(opts) end - defp build_js(opts) do + defp parse_layout(nil, default), do: default + defp parse_layout("flat", _default), do: :flat + defp parse_layout("split", _default), do: :split + + defp parse_layout(other, _default), + do: Mix.raise("Invalid output layout: #{inspect(other)}; expected flat or split") + + defp build_all(opts) do Mix.shell().info("Building #{inspect(opts[:entry])}...") - {us, result} = :timer.tc(fn -> Volt.Builder.build(opts) end) + {us, result} = :timer.tc(fn -> Volt.build(opts) end) ms = div(us, 1000) case result do - {:ok, %{js: js, css: css, manifest: manifest} = result} -> - case js do + {:ok, %Volt.Build.Result{assets: assets, styles: styles, manifest: manifest}} -> + case assets.js do %{path: path} -> Mix.shell().info(" #{Path.basename(path)} #{format_file(path)}") @@ -146,14 +167,12 @@ defmodule Mix.Tasks.Volt.Build do :ok end - if chunks = result[:chunks] do - for chunk <- chunks, chunk.type != :entry do - Mix.shell().info(" #{Path.basename(chunk.path)} #{format_file(chunk.path)}") - end + for chunk <- assets.chunks, chunk.type != :entry do + Mix.shell().info(" #{Path.basename(chunk.path)} #{format_file(chunk.path)}") end - if css do - Mix.shell().info(" #{Path.basename(css.path)} #{format_file(css.path)}") + for style <- styles do + Mix.shell().info(" #{Path.basename(style.path)} #{format_file(style.path)}") end Mix.shell().info(" manifest.json #{map_size(manifest)} entries") @@ -165,66 +184,10 @@ defmodule Mix.Tasks.Volt.Build do end end - defp build_tailwind(parsed, tailwind_config, outdir, minify, config_hash, root) do - cli_sources = Keyword.get_values(parsed, :tailwind_source) - hash = Keyword.get(parsed, :hash, config_hash) - - sources = - case cli_sources do - [] -> - tailwind_config[:sources] || - [ - %{base: Volt.Paths.lib(), pattern: "**/*.{ex,heex}"}, - %{base: Volt.Paths.assets_dir(), pattern: "**/*.{vue,ts,tsx,js,jsx}"} - ] - - list -> - Enum.map(list, &%{base: &1, pattern: "**/*"}) - end - - {css_input, css_base} = - case Keyword.get(parsed, :tailwind_css) || tailwind_config[:css] do - nil -> {nil, File.cwd!()} - path -> {File.read!(path), Path.dirname(path)} - end - - Mix.shell().info("Building Tailwind CSS...") - - {us, result} = - :timer.tc(fn -> - with {:ok, css} <- - Volt.Tailwind.build( - sources: sources, - css: css_input, - css_base: css_base, - minify: false - ) do - Volt.Builder.Writer.build_style_entry( - "app", - css, - Path.join(outdir, "css"), - hash, - nil, - minify: minify, - root: root - ) - end - end) - - ms = div(us, 1000) - - case result do - {:ok, %{css: %{path: path, size: size}, manifest: manifest}} -> - css_outdir = Path.dirname(path) - Volt.Builder.Writer.write_manifest(css_outdir, manifest) - - Mix.shell().info(" #{Path.basename(path)} #{Volt.Format.format_size(size)}") - Mix.shell().info(" manifest.json #{map_size(manifest)} entries") - Mix.shell().info("Built Tailwind in #{ms}ms") - - {:error, reason} -> - Mix.shell().error("Tailwind build failed: #{inspect(reason)}") - exit({:shutdown, 1}) + defp tailwind_sources(parsed, tailwind_config) do + case Keyword.get_values(parsed, :tailwind_source) do + [] -> tailwind_config[:sources] + list -> Enum.map(list, &%{base: &1, pattern: "**/*"}) end end diff --git a/lib/mix/tasks/volt.dev.ex b/lib/mix/tasks/volt/dev.ex similarity index 71% rename from lib/mix/tasks/volt.dev.ex rename to lib/mix/tasks/volt/dev.ex index fb9e219..381b4cf 100644 --- a/lib/mix/tasks/volt.dev.ex +++ b/lib/mix/tasks/volt/dev.ex @@ -23,6 +23,7 @@ defmodule Mix.Tasks.Volt.Dev do """ use Mix.Task + alias Volt.Dev alias Volt.Config alias Volt.Paths @@ -52,9 +53,17 @@ defmodule Mix.Tasks.Volt.Dev do root = Keyword.get(parsed, :root) || to_string(config.root) target = Keyword.get(parsed, :target) || to_string(config.target) + tailwind_root = + tailwind_config + |> Keyword.put(:css, Keyword.get(parsed, :tailwind_css) || tailwind_config[:css]) + |> Volt.Config.Tailwind.new() + tailwind? = - Keyword.get(parsed, :tailwind) || - (tailwind_config != [] and Keyword.get(parsed, :tailwind, true)) + Keyword.get( + parsed, + :tailwind, + not is_nil(parsed[:tailwind_css]) or Volt.Config.Tailwind.enabled?(tailwind_config) + ) cli_watch_dirs = Keyword.get_values(parsed, :watch_dir) cli_reload_dirs = Keyword.get_values(parsed, :reload_dir) @@ -80,24 +89,25 @@ defmodule Mix.Tasks.Volt.Dev do watch_dirs = if tailwind? and watch_dirs == [], do: [Paths.lib()], else: watch_dirs - tailwind_css = Keyword.get(parsed, :tailwind_css) || tailwind_config[:css] - - if tailwind? do - initial_build(tailwind_config, tailwind_css, parsed) - end + tailwind_css = tailwind_root.css opts = [ + id: profile || :default, root: root, watch_dirs: watch_dirs, reload_dirs: reload_dirs, watch_ignored: watch_ignored, + tailwind_key: tailwind_key(profile, tailwind_root), tailwind: tailwind?, tailwind_css: tailwind_css, - tailwind_outdir: Keyword.get(parsed, :tailwind_outdir, Paths.static_css()), + tailwind_name: tailwind_root.name, + tailwind_sources: tailwind_root.sources, + tailwind_url: tailwind_root.dev_url, + tailwind_sink: Keyword.get(parsed, :tailwind_outdir, Paths.static_css()), target: target ] - {:ok, _pid} = Volt.Watcher.start_link(opts) + {:ok, _pid} = Dev.start(opts) Mix.shell().info("[Volt] Watching #{opts[:root]}...") @@ -114,39 +124,19 @@ defmodule Mix.Tasks.Volt.Dev do end unless iex_running?() do - Process.sleep(:infinity) - end - end + session = Dev.session_identity(opts) - defp initial_build(tailwind_config, tailwind_css, parsed) do - sources = - tailwind_config[:sources] || - [ - %{base: Paths.lib(), pattern: "**/*.{ex,heex,eex}"}, - %{base: Paths.assets_dir(), pattern: "**/*.{vue,ts,tsx,js,jsx}"} - ] - - {css_input, css_base} = - case tailwind_css do - nil -> {nil, File.cwd!()} - path -> {File.read!(path), Path.dirname(path)} + try do + Process.sleep(:infinity) + after + Dev.stop(session) end - - case Volt.Tailwind.build(sources: sources, css: css_input, css_base: css_base) do - {:ok, css} -> - outdir = Keyword.get(parsed, :tailwind_outdir, Paths.static_css()) - File.mkdir_p!(outdir) - File.write!(Path.join(outdir, "app.css"), css) - - Mix.shell().info( - "[Volt] Initial Tailwind build: #{Volt.Format.format_size(byte_size(css))}" - ) - - {:error, reason} -> - Mix.shell().error("[Volt] Tailwind build failed: #{inspect(reason)}") end end + defp tailwind_key(profile, root), + do: {:profile, profile || :default, root.css || root.name} + defp parse_profile(args), do: Volt.Config.Profile.from_args(args) @dialyzer {:nowarn_function, iex_running?: 0} diff --git a/lib/mix/tasks/volt.install.ex b/lib/mix/tasks/volt/install.ex similarity index 100% rename from lib/mix/tasks/volt.install.ex rename to lib/mix/tasks/volt/install.ex diff --git a/lib/mix/tasks/volt.js.check.ex b/lib/mix/tasks/volt/js/check.ex similarity index 96% rename from lib/mix/tasks/volt.js.check.ex rename to lib/mix/tasks/volt/js/check.ex index 701573b..9556868 100644 --- a/lib/mix/tasks/volt.js.check.ex +++ b/lib/mix/tasks/volt/js/check.ex @@ -22,8 +22,8 @@ defmodule Mix.Tasks.Volt.Js.Check do Mix.Task.run("app.config") opts = parse_args!(args) - format_files = Volt.JS.Helpers.discover_format_files() - lint_files = Volt.JS.Helpers.discover_files(tool: :lint) + format_files = Volt.JS.Discovery.format_files() + lint_files = Volt.JS.Discovery.files(tool: :lint) if format_files == [] and lint_files == [] do Mix.shell().info("No files found") diff --git a/lib/mix/tasks/volt.js.format.ex b/lib/mix/tasks/volt/js/format.ex similarity index 93% rename from lib/mix/tasks/volt.js.format.ex rename to lib/mix/tasks/volt/js/format.ex index e1f2e27..d6889bf 100644 --- a/lib/mix/tasks/volt.js.format.ex +++ b/lib/mix/tasks/volt/js/format.ex @@ -17,7 +17,7 @@ defmodule Mix.Tasks.Volt.Js.Format do def run(_args) do Mix.Task.run("app.config") - files = Volt.JS.Helpers.discover_format_files() + files = Volt.JS.Discovery.format_files() if files == [] do Mix.shell().info("No formattable files found") diff --git a/lib/mix/tasks/volt.lint.ex b/lib/mix/tasks/volt/lint.ex similarity index 98% rename from lib/mix/tasks/volt.lint.ex rename to lib/mix/tasks/volt/lint.ex index fd17bb2..0b5317e 100644 --- a/lib/mix/tasks/volt.lint.ex +++ b/lib/mix/tasks/volt/lint.ex @@ -79,7 +79,7 @@ defmodule Mix.Tasks.Volt.Lint do custom_rules = Keyword.get(config, :custom_rules, []) fix = Keyword.get(parsed, :fix, false) - files = Volt.JS.Helpers.discover_files(tool: :lint, only: ~w(.js .ts .jsx .tsx)) + files = Volt.JS.Discovery.files(tool: :lint, only: ~w".js .ts .jsx .tsx") if files == [] do Mix.shell().info("No lintable files found") diff --git a/lib/volt.ex b/lib/volt.ex index 875f202..7b31950 100644 --- a/lib/volt.ex +++ b/lib/volt.ex @@ -21,6 +21,10 @@ defmodule Volt do alias Volt.URL + @doc "Build the complete configured frontend output." + @spec build(keyword()) :: {:ok, Volt.Build.Result.t()} | {:error, term()} + def build(opts \\ []), do: Volt.Build.run(opts) + @doc """ Returns the browser path for a Volt-managed static asset. diff --git a/lib/volt/application.ex b/lib/volt/application.ex index 5d54fce..10c810b 100644 --- a/lib/volt/application.ex +++ b/lib/volt/application.ex @@ -5,6 +5,7 @@ defmodule Volt.Application do @impl true def start(_type, _args) do Volt.Cache.create_table() + Volt.Dev.Assets.create_table() Volt.HMR.ImportGraph.create_table() Volt.HMR.GlobGraph.create_table() Volt.HMR.StyleGraph.create_table() @@ -14,7 +15,9 @@ defmodule Volt.Application do {Registry, keys: :duplicate, name: Volt.HMR.Registry}, {Registry, keys: :unique, name: Volt.Dev.WatcherRegistry}, {DynamicSupervisor, strategy: :one_for_one, name: Volt.Dev.WatcherSupervisor}, - {Volt.Tailwind, Volt.Config.tailwind()} + {Registry, keys: :unique, name: Volt.Tailwind.Registry}, + {DynamicSupervisor, strategy: :one_for_one, name: Volt.Tailwind.WorkerSupervisor}, + Volt.Tailwind.Runtime ] opts = [strategy: :one_for_one, name: Volt.Supervisor] diff --git a/lib/volt/assets.ex b/lib/volt/assets.ex index 074fba6..153f993 100644 --- a/lib/volt/assets.ex +++ b/lib/volt/assets.ex @@ -95,8 +95,31 @@ defmodule Volt.Assets do @doc "Generate a JS module for an asset and return emitted asset metadata." @spec emit_js_module(String.t(), keyword()) :: - {:ok, %{code: String.t(), assets: [String.t() | map()]}} | {:error, term()} + {:ok, %{code: String.t(), assets: [Volt.Builder.Asset.t()]}} | {:error, term()} def emit_js_module(path, opts \\ []) do + with {:ok, prepared} <- prepare_js_module(path, opts) do + case Keyword.get(opts, :outdir) do + nil -> + :ok + + outdir -> + write_artifacts(prepared.artifacts, outdir) + end + + {:ok, %{code: prepared.code, assets: prepared.assets}} + end + end + + @doc "Write prepared binary assets for the immediate-emission APIs." + def write_artifacts(artifacts, outdir) do + Enum.each(artifacts, fn %Volt.Builder.Artifact{file: file, content: content} -> + File.mkdir_p!(outdir) + File.write!(Path.join(outdir, file), content) + end) + end + + @doc "Prepare an asset module and its output artifacts without writing files." + def prepare_js_module(path, opts \\ []) do cond do Keyword.get(opts, :raw, false) -> raw_asset(path) @@ -133,23 +156,22 @@ defmodule Volt.Assets do """ @spec copy_hashed(String.t(), String.t()) :: {:ok, String.t()} def copy_hashed(source_path, outdir) do - content = File.read!(source_path) - ext = Path.extname(source_path) - name = Path.basename(source_path, ext) - hash = Volt.Format.content_hash(content) - filename = "#{name}-#{hash}#{ext}" - dest = Path.join(outdir, filename) - + artifact = prepare_hashed(source_path) File.mkdir_p!(outdir) - File.write!(dest, content) + File.write!(Path.join(outdir, artifact.file), artifact.content) + {:ok, artifact.file} + end - {:ok, filename} + @doc false + @spec prepare_hashed(String.t()) :: Volt.Builder.Artifact.t() + def prepare_hashed(source_path) do + Volt.Builder.Artifact.asset(source_path, File.read!(source_path)) end @doc "Build manifest metadata for an emitted asset." - @spec manifest_asset(String.t(), String.t(), keyword()) :: map() + @spec manifest_asset(String.t(), String.t(), keyword()) :: Volt.Builder.Asset.t() def manifest_asset(source_path, filename, opts \\ []) do - %{ + %Volt.Builder.Asset{ src: source_path |> asset_src(Keyword.get(opts, :root)) |> String.trim_leading("/"), file: filename } @@ -254,10 +276,10 @@ defmodule Volt.Assets do url = Keyword.get(opts, :url_path) || Volt.URL.join(prefix, Path.basename(path)) {:ok, asset_module(url)} - outdir -> - {:ok, filename} = copy_hashed(path, outdir) - asset = manifest_asset(path, filename, root: Keyword.get(opts, :root)) - {:ok, asset_module(Volt.URL.join(prefix, filename), [asset])} + _outdir -> + artifact = prepare_hashed(path) + asset = manifest_asset(path, artifact.file, root: Keyword.get(opts, :root)) + {:ok, asset_module(Volt.URL.join(prefix, artifact.file), [asset], [artifact])} end end @@ -274,8 +296,8 @@ defmodule Volt.Assets do end end - defp asset_module(value, assets \\ []) do - %{code: export_default_literal(value), assets: assets} + defp asset_module(value, assets \\ [], artifacts \\ []) do + %{code: export_default_literal(value), assets: assets, artifacts: artifacts} end defp export_default_literal(value) do diff --git a/lib/volt/build.ex b/lib/volt/build.ex new file mode 100644 index 0000000..48ec566 --- /dev/null +++ b/lib/volt/build.ex @@ -0,0 +1,135 @@ +defmodule Volt.Build do + @moduledoc """ + Builds the complete configured frontend output. + + This orchestrator composes Tailwind roots with the ordinary Volt module graph, + merges their typed build results, and publishes one final manifest. + """ + + @spec run(keyword()) :: {:ok, Volt.Build.Result.t()} | {:error, term()} + def run(opts \\ []) do + {profile, opts} = Keyword.pop(opts, :profile) + config = Volt.Config.build(profile, opts) + tailwind = Keyword.get(opts, :tailwind, Volt.Config.tailwind(profile)) + entries = List.wrap(config.entry) + root_outdir = to_string(config.outdir) + layout = Volt.Build.Layout.new(config.output_layout, config.assets_dir) + + with {:ok, tailwind_result, tailwind_plan} <- + build_tailwind(tailwind, config, opts, layout), + {:ok, asset_result, asset_plan} <- build_assets(entries, config, opts, layout) do + tailwind_result = prefix_manifest(tailwind_result, layout.styles) + asset_result = prefix_manifest(asset_result, layout.scripts) + + artifacts = + prefix_artifacts(tailwind_plan, layout.styles) ++ + prefix_artifacts(asset_plan, layout.scripts) + + with {:ok, result} <- build_result(tailwind_result, asset_result), + {:ok, plan} <- + Volt.Builder.Plan.new(artifacts ++ manifest_artifacts(result.manifest, opts)), + :ok <- Volt.Builder.Plan.validate_manifest(plan, result.manifest), + {:ok, destination, plan} <- + Volt.PublicDir.prepare_output( + plan, + root_outdir, + Volt.PublicDir.resolve(config.public_dir), + root_outdir + ), + :ok <- Volt.Builder.Writer.write_plan(destination, plan) do + {:ok, result} + end + end + end + + defp build_tailwind(false, _config, _opts, _layout), do: empty_plan() + defp build_tailwind(true, config, opts, layout), do: prepare_tailwind([], config, opts, layout) + defp build_tailwind([], _config, _opts, _layout), do: empty_plan() + defp build_tailwind(nil, _config, _opts, _layout), do: empty_plan() + + defp build_tailwind(tailwind, config, opts, layout), + do: prepare_tailwind(tailwind, config, opts, layout) + + defp prepare_tailwind(tailwind, config, opts, layout) do + overrides = + [] + |> put_present(:css, opts[:tailwind_css]) + |> put_present(:name, opts[:tailwind_name]) + |> put_present(:sources, opts[:tailwind_sources]) + + root = Volt.Config.Tailwind.new(tailwind, overrides) + + Volt.Tailwind.Build.prepare(root, + outdir: Path.join(to_string(config.outdir), layout.styles), + hash: config.hash, + minify: config.minify, + root: to_string(config.root), + asset_url_prefix: Volt.URL.join(config.asset_url_prefix, layout.styles) + ) + end + + defp build_assets([], _config, _opts, _layout), do: empty_plan() + + defp build_assets(entries, config, opts, layout) do + builder_outdir = Path.join(to_string(config.outdir), layout.scripts) + asset_url_prefix = Volt.URL.join(config.asset_url_prefix, layout.scripts) + + config + |> Map.from_struct() + |> Map.put(:entry, entries) + |> Map.put(:outdir, builder_outdir) + |> Map.put(:asset_url_prefix, asset_url_prefix) + |> Map.put(:write_manifest, false) + |> Map.merge(Map.new(Keyword.take(opts, [:name]))) + |> Map.to_list() + |> Volt.Builder.prepare() + end + + defp empty_plan, do: {:ok, %Volt.Builder.Result{}, %Volt.Builder.Plan{artifacts: []}} + + defp prefix_artifacts(plan, ""), do: plan.artifacts + + defp prefix_artifacts(plan, prefix) do + Enum.map(plan.artifacts, fn artifact -> + %{artifact | file: Path.join(prefix, artifact.file)} + end) + end + + defp manifest_artifacts(manifest, opts) do + if Keyword.get(opts, :write_manifest, true), + do: [%Volt.Builder.Artifact{file: "manifest.json", content: Jason.encode!(manifest)}], + else: [] + end + + defp build_result(styles_result, assets_result) do + collisions = + Volt.Builder.ManifestEntry.conflicts(styles_result.manifest, assets_result.manifest) + + case Enum.sort(collisions) do + [] -> + {:ok, + %Volt.Build.Result{ + assets: assets_result, + styles: styles_result.styles ++ assets_result.styles, + manifest: Map.merge(styles_result.manifest, assets_result.manifest) + }} + + keys -> + {:error, {:manifest_collision, keys}} + end + end + + defp prefix_manifest(result, ""), do: result + + defp prefix_manifest(result, prefix) do + manifest = + Map.new(result.manifest, fn {key, %Volt.Builder.ManifestEntry{} = entry} -> + {key, Volt.Builder.ManifestEntry.prefix_paths(entry, prefix)} + end) + + %{result | manifest: manifest} + end + + defp put_present(opts, _key, nil), do: opts + defp put_present(opts, key, value), do: Keyword.put(opts, key, value) +end diff --git a/lib/volt/build/layout.ex b/lib/volt/build/layout.ex new file mode 100644 index 0000000..bac5958 --- /dev/null +++ b/lib/volt/build/layout.ex @@ -0,0 +1,36 @@ +defmodule Volt.Build.Layout do + @moduledoc "Output locations for a complete frontend build." + + @enforce_keys [:scripts, :styles] + defstruct [:scripts, :styles] + + @type t :: %__MODULE__{scripts: String.t(), styles: String.t()} + + @spec new(:split | :flat, String.t()) :: t() + def new(layout, assets_dir \\ "") do + if assets_dir != "" and + (Path.type(assets_dir) != :relative or + Enum.any?(Path.split(assets_dir), &(&1 in [".", ".."])) or + String.contains?(assets_dir, ["\\", ":", <<0>>])) do + raise ArgumentError, "assets_dir must be a relative directory within outdir" + end + + layout = preset(layout) + + %__MODULE__{ + scripts: join(assets_dir, layout.scripts), + styles: join(assets_dir, layout.styles) + } + end + + defp join("", suffix), do: suffix + defp join(prefix, ""), do: prefix + defp join(prefix, suffix), do: Path.join(prefix, suffix) + + defp preset(:split), do: %__MODULE__{scripts: "js", styles: "css"} + defp preset(:flat), do: %__MODULE__{scripts: "", styles: ""} + + defp preset(other) do + raise ArgumentError, "expected output_layout to be :split or :flat, got: #{inspect(other)}" + end +end diff --git a/lib/volt/build/result.ex b/lib/volt/build/result.ex new file mode 100644 index 0000000..e778331 --- /dev/null +++ b/lib/volt/build/result.ex @@ -0,0 +1,12 @@ +defmodule Volt.Build.Result do + @moduledoc "Complete frontend build result with script and stylesheet outputs." + + @enforce_keys [:assets, :styles, :manifest] + defstruct assets: nil, styles: [], manifest: %{} + + @type t :: %__MODULE__{ + assets: Volt.Builder.Result.t(), + styles: [Volt.Builder.OutputFile.t()], + manifest: %{String.t() => Volt.Builder.ManifestEntry.t()} + } +end diff --git a/lib/volt/builder.ex b/lib/volt/builder.ex index 8e293d1..65e3ffc 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -45,6 +45,7 @@ defmodule Volt.Builder do * `:chunks` — manual chunk definitions, map of chunk name to list of patterns: chunks: %{"vendor" => ["vue", "vue-router"], "ui" => ["assets/src/components"]} + * `:write_manifest` — write `manifest.json` after building (default: `true`) * `:external` — specifiers to exclude from the bundle and access as globals. Accepts a list (global name auto-derived) or a map of `specifier => global_name`: @@ -53,17 +54,33 @@ defmodule Volt.Builder do """ @spec build(keyword()) :: {:ok, Volt.Builder.Result.t()} | {:error, term()} def build(opts) do + outdir = opts |> Keyword.get(:outdir, Paths.static()) |> Path.expand() + + public_dir = opts |> Keyword.get(:public_dir, false) |> Volt.PublicDir.resolve() + + with {:ok, result, plan} <- prepare(opts), + {:ok, static_root, plan} <- + Volt.PublicDir.prepare_output( + plan, + outdir, + public_dir, + Path.dirname(outdir) + ), + :ok <- Volt.Builder.Writer.write_plan(static_root, plan) do + {:ok, result} + end + end + + @doc "Prepare graph output and its manifest without writing destination files." + def prepare(opts) do plugins = Keyword.get(opts, :plugins, []) entries = opts |> Keyword.fetch!(:entry) |> List.wrap() |> Enum.map(&resolve_entry(&1, plugins)) - public_dir = opts |> Keyword.get(:public_dir, false) |> Volt.PublicDir.resolve() name = Keyword.get(opts, :name) {ctx, build_ctx} = build_contexts(entries, opts) - Volt.PublicDir.copy(public_dir, Path.dirname(build_ctx.outdir)) - expanded_entries = entries |> Enum.flat_map(&expand_entry(&1, name)) |> unique_entry_names() results = @@ -73,9 +90,17 @@ defmodule Volt.Builder do build_isolated_entries(expanded_entries, ctx, build_ctx) end - with {:ok, result} <- finalize_build_results(results) do - Volt.Builder.Writer.write_manifest(build_ctx.outdir, result.manifest) - {:ok, result} + with {:ok, result, plan} <- finalize_build_results(results), + :ok <- Volt.Builder.Plan.validate_manifest(plan, result.manifest) do + artifacts = + if Keyword.get(opts, :write_manifest, true), + do: [ + %Volt.Builder.Artifact{file: "manifest.json", content: Jason.encode!(result.manifest)} + | plan.artifacts + ], + else: plan.artifacts + + with {:ok, plan} <- Volt.Builder.Plan.new(artifacts), do: {:ok, result, plan} end end @@ -89,7 +114,8 @@ defmodule Volt.Builder do defp build_shared_entries(entries, ctx, build_ctx) do with {:ok, collected} <- collect_shared_entries(entries, ctx), false <- label_collision?(collected.path_labels), - {:ok, worker_results} <- build_worker_results(collected.workers, ctx, build_ctx), + {:ok, worker_results, worker_artifacts, worker_metadata} <- + build_worker_results(collected.workers, ctx, build_ctx), {:ok, compiled} <- compile_all(collected.modules, build_ctx.target, ctx) do compiled = rewrite_nonlocal_labels(compiled, collected.specifier_labels, collected.path_labels) @@ -112,7 +138,9 @@ defmodule Volt.Builder do asset_url_prefix: build_ctx.asset_url_prefix } - case Output.build_shared_entries( + compiled = %{compiled | artifacts: compiled.artifacts ++ worker_artifacts} + + case Output.prepare_shared_entries( entries, compiled, collected.modules, @@ -123,7 +151,7 @@ defmodule Volt.Builder do build_isolated_entries(entries, ctx, build_ctx) |> finalize_build_results() result -> - result + merge_worker_metadata(result, worker_metadata) end else true -> build_isolated_entries(entries, ctx, build_ctx) |> finalize_build_results() @@ -275,8 +303,10 @@ defmodule Volt.Builder do with {:ok, modules, dep_map, workers, specifier_labels, path_labels} <- Collector.collect(entry, ctx), {:ok, compiled} <- compile_all(modules, target, ctx), - {:ok, worker_results} <- build_worker_results(workers, ctx, build_ctx) do + {:ok, worker_results, worker_artifacts, worker_metadata} <- + build_worker_results(workers, ctx, build_ctx) do compiled = rewrite_nonlocal_labels(compiled, specifier_labels, path_labels) + compiled = %{compiled | artifacts: compiled.artifacts ++ worker_artifacts} output_ctx = %Volt.Builder.OutputContext{ plugins: ctx.plugins, @@ -300,11 +330,14 @@ defmodule Volt.Builder do code_splitting and (has_dynamic_imports?(dep_map) or build_ctx.chunks != %{}) - if use_chunks do - Output.build_chunks(entry, name, compiled, {modules, dep_map}, out) - else - Output.build_single(entry, name, compiled, out) - end + prepared = + if use_chunks do + Output.prepare_chunks(entry, name, compiled, {modules, dep_map}, out) + else + Output.prepare_single(entry, name, compiled, out) + end + + merge_worker_metadata(prepared, worker_metadata) end end @@ -316,9 +349,11 @@ defmodule Volt.Builder do {:ok, compiled} <- Volt.Pipeline.compile(entry, source, minify: bundle_opts[:minify] || false, + node_modules: bundle_opts[:node_modules], + resolve_dirs: bundle_opts[:resolve_dirs] || [], mode: :production ) do - Volt.Builder.Writer.build_style_entry( + Volt.Builder.Writer.prepare_style_entry( name, compiled.code, outdir, @@ -448,9 +483,10 @@ defmodule Volt.Builder do duplicate_worker_basenames = duplicate_worker_basenames(worker_specs) - Enum.reduce_while(worker_specs, {:ok, %{}}, fn {_specifier, resolved_path}, {:ok, acc} -> + Enum.reduce_while(worker_specs, {:ok, %{}, [], []}, fn {_specifier, resolved_path}, + {:ok, acc, artifacts, metadata} -> if Map.has_key?(acc, resolved_path) do - {:cont, {:ok, acc}} + {:cont, {:ok, acc, artifacts, metadata}} else worker_name = worker_output_name(resolved_path, duplicate_worker_basenames) @@ -461,14 +497,56 @@ defmodule Volt.Builder do ctx, %{build_ctx | code_splitting: false} ) do - {:ok, %{js: %{path: path}}} -> - {:cont, {:ok, Map.put(acc, resolved_path, Path.basename(path))}} + {:ok, %{js: %{path: path}} = result, plan} -> + {:cont, + {:ok, Map.put(acc, resolved_path, Path.basename(path)), [plan.artifacts | artifacts], + [result | metadata]}} {:error, reason} -> {:halt, {:error, {:worker_build_failed, resolved_path, reason}}} end end end) + |> case do + {:ok, urls, artifacts, metadata} -> + {:ok, urls, List.flatten(artifacts), Enum.reverse(metadata)} + + {:error, _} = error -> + error + end + end + + defp merge_worker_metadata({:error, _} = error, _workers), do: error + + defp merge_worker_metadata({:ok, result, plan}, workers) do + Enum.reduce_while(workers, {:ok, result, plan}, fn worker, {:ok, acc, plan} -> + case Volt.Builder.ManifestEntry.conflicts(worker.manifest, acc.manifest) do + [] -> + merged = %{ + acc + | manifest: Map.merge(acc.manifest, worker.manifest), + styles: [worker.styles | acc.styles], + chunks: [worker.js, worker.chunks | acc.chunks] + } + + {:cont, {:ok, merged, plan}} + + keys -> + {:halt, {:error, {:manifest_collision, Enum.sort(keys)}}} + end + end) + |> case do + {:ok, merged, plan} -> + {:ok, + %{ + merged + | styles: merged.styles |> List.flatten() |> Enum.uniq(), + chunks: merged.chunks |> List.flatten() |> Enum.uniq() + }, plan} + + {:error, _} = error -> + error + end end defp duplicate_worker_basenames(worker_specs) do @@ -485,7 +563,7 @@ defmodule Volt.Builder do name = resolved_path |> Path.basename() |> Path.rootname() if MapSet.member?(duplicate_basenames, name) do - "#{name}-#{Volt.Format.content_hash(resolved_path)}" + "#{name}-#{Volt.Builder.Naming.hash(resolved_path)}" else name end @@ -502,8 +580,11 @@ defmodule Volt.Builder do defp compile_modules(modules, ctx) do Enum.reduce_while(modules, {:ok, []}, fn {path, label, source}, {:ok, acc} -> case compile_module(path, label, source, ctx) do - {:ok, js, css, assets} -> {:cont, {:ok, [{label, js, css_part(path, css), assets} | acc]}} - {:error, _} = error -> {:halt, error} + {:ok, js, css, assets, artifacts} -> + {:cont, {:ok, [{label, js, css_part(path, css), assets, artifacts} | acc]}} + + {:error, _} = error -> + {:halt, error} end end) end @@ -522,16 +603,27 @@ defmodule Volt.Builder do end defp merge_compiled(compiled) do - {js_files, css_parts, assets} = - compiled - |> Enum.reverse() - |> Enum.reduce({[], [], []}, fn {label, js, css, assets}, {js_acc, css_acc, asset_acc} -> - {[{label, js} | js_acc], if(css, do: [css | css_acc], else: css_acc), - [assets | asset_acc]} + result = + Enum.reduce(Enum.reverse(compiled), %Volt.Builder.Compiled{}, fn {label, js, css, assets, + artifacts}, + acc -> + %{ + acc + | scripts: [{label, js} | acc.scripts], + styles: if(css, do: [css | acc.styles], else: acc.styles), + assets: [assets | acc.assets], + artifacts: [artifacts | acc.artifacts] + } end) {:ok, - {Enum.reverse(js_files), Enum.reverse(css_parts), assets |> List.flatten() |> Enum.uniq()}} + %{ + result + | scripts: Enum.reverse(result.scripts), + styles: Enum.reverse(result.styles), + assets: result.assets |> List.flatten() |> Enum.uniq(), + artifacts: result.artifacts |> List.flatten() |> Enum.uniq() + }} end defp compile_module(module_id, _label, source, ctx) do @@ -552,7 +644,7 @@ defmodule Volt.Builder do true -> case Volt.Builder.Compiler.compile(module_id, source, ctx) do - {:ok, %{code: code, css: css}} -> {:ok, code, css, []} + {:ok, %{code: code, css: css}} -> {:ok, code, css, [], []} {:error, _} = error -> error end end @@ -578,9 +670,12 @@ defmodule Volt.Builder do root: ctx.asset_root ] - case Volt.Assets.emit_js_module(path, asset_opts) do - {:ok, %{code: js, assets: assets}} -> {:ok, js, nil, assets} - {:error, _} = error -> error + case Volt.Assets.prepare_js_module(path, asset_opts) do + {:ok, %{code: js, assets: assets, artifacts: artifacts}} -> + {:ok, js, nil, assets, artifacts} + + {:error, _} = error -> + error end end @@ -590,12 +685,16 @@ defmodule Volt.Builder do defp compile_css_import(path, source, ctx) do case Volt.Builder.Compiler.compile(path, source, ctx) do - {:ok, %{code: css}} -> {:ok, "export default undefined;", css, []} + {:ok, %{code: css}} -> {:ok, "export default undefined;", css, [], []} {:error, _} = error -> error end end - defp rewrite_nonlocal_labels({js_files, css_parts, assets}, specifier_labels, path_labels) do + defp rewrite_nonlocal_labels( + %Volt.Builder.Compiled{scripts: js_files} = compiled, + specifier_labels, + path_labels + ) do label_to_path = Map.new(path_labels, fn {path, label} -> {label, path} end) global_specifier_map = @@ -627,7 +726,7 @@ defmodule Volt.Builder do {label, new_code} end) - {js_files, css_parts, assets} + %{compiled | scripts: js_files} end defp relative_label(from_label, to_label) do @@ -839,20 +938,51 @@ defmodule Volt.Builder do defp to_string_or_nil(value), do: to_string(value) defp finalize_build_results(results) do - case Enum.split_with(results, &match?({:ok, _}, &1)) do - {[{:ok, single}], []} -> {:ok, single} - {successes, []} when successes != [] -> {:ok, merge_build_results(successes)} - {_, [first_error | _]} -> first_error - end - end + Enum.reduce_while( + results, + {:ok, %Volt.Builder.Result{}, []}, + fn + {:error, _} = error, _acc -> + {:halt, error} - defp merge_build_results(results) do - Enum.reduce(results, %Volt.Builder.Result{}, fn {:ok, result}, acc -> - %Volt.Builder.Result{ - js: [result.js | acc.js], - css: result.css || acc.css, - manifest: Map.merge(acc.manifest, result.manifest) - } - end) + {:ok, result, plan}, {:ok, acc, artifact_groups} -> + conflicts = Volt.Builder.ManifestEntry.conflicts(result.manifest, acc.manifest) + + with [] <- conflicts do + merged = %Volt.Builder.Result{ + js: [result.js | acc.js], + css: result.css || acc.css, + styles: [result.styles | acc.styles], + chunks: [result.chunks | acc.chunks], + manifest: Map.merge(acc.manifest, result.manifest) + } + + {:cont, {:ok, merged, [plan.artifacts | artifact_groups]}} + else + keys -> {:halt, {:error, {:manifest_collision, Enum.sort(keys)}}} + end + end + ) + |> case do + {:ok, result, artifact_groups} -> + js = + case result.js |> Enum.reverse() |> List.flatten() do + [single] -> single + entries -> entries + end + + with {:ok, plan} <- Volt.Builder.Plan.new(List.flatten(artifact_groups)) do + {:ok, + %{ + result + | js: js, + styles: result.styles |> Enum.reverse() |> List.flatten() |> Enum.uniq(), + chunks: result.chunks |> Enum.reverse() |> List.flatten() + }, plan} + end + + {:error, _} = error -> + error + end end end diff --git a/lib/volt/builder/artifact.ex b/lib/volt/builder/artifact.ex new file mode 100644 index 0000000..5d61222 --- /dev/null +++ b/lib/volt/builder/artifact.ex @@ -0,0 +1,33 @@ +defmodule Volt.Builder.Artifact do + @moduledoc false + + @enforce_keys [:file, :content] + defstruct [:file, :content] + + @type t :: %__MODULE__{file: String.t(), content: binary()} + + @spec asset(String.t(), binary()) :: t() + def asset(source_path, content) do + ext = Path.extname(source_path) + name = Path.basename(source_path, ext) + hash = Volt.Builder.Naming.hash(content) + %__MODULE__{file: "#{name}-#{hash}#{ext}", content: content} + end + + @spec javascript(String.t(), binary(), binary() | nil, keyword()) :: [t()] + def javascript(filename, code, sourcemap, opts \\ []) do + code = + if sourcemap && !Keyword.get(opts, :hidden, false) do + code <> "\n//# sourceMappingURL=#{filename}.map\n" + else + code + end + + script = %__MODULE__{file: filename, content: code} + + case sourcemap do + nil -> [script] + map -> [script, %__MODULE__{file: "#{filename}.map", content: map}] + end + end +end diff --git a/lib/volt/builder/asset.ex b/lib/volt/builder/asset.ex new file mode 100644 index 0000000..e675a12 --- /dev/null +++ b/lib/volt/builder/asset.ex @@ -0,0 +1,8 @@ +defmodule Volt.Builder.Asset do + @moduledoc "Metadata for one asset emitted by a production build." + + @enforce_keys [:src, :file] + defstruct [:src, :file] + + @type t :: %__MODULE__{src: String.t(), file: String.t()} +end diff --git a/lib/volt/builder/context/build.ex b/lib/volt/builder/build_context.ex similarity index 100% rename from lib/volt/builder/context/build.ex rename to lib/volt/builder/build_context.ex diff --git a/lib/volt/builder/bundle.ex b/lib/volt/builder/bundle.ex index 6063f97..7b6c8f4 100644 --- a/lib/volt/builder/bundle.ex +++ b/lib/volt/builder/bundle.ex @@ -17,6 +17,6 @@ defmodule Volt.Builder.Bundle do sourcemap: String.t() | nil, files: [Path.t()], css: String.t() | nil, - assets: [term()] + assets: [Volt.Builder.Asset.t()] } end diff --git a/lib/volt/builder/compiled.ex b/lib/volt/builder/compiled.ex new file mode 100644 index 0000000..35561d7 --- /dev/null +++ b/lib/volt/builder/compiled.ex @@ -0,0 +1,5 @@ +defmodule Volt.Builder.Compiled do + @moduledoc "Compiled modules and prepared binary assets before bundling." + + defstruct scripts: [], styles: [], assets: [], artifacts: [] +end diff --git a/lib/volt/builder/css.ex b/lib/volt/builder/css.ex index 71b025d..711db38 100644 --- a/lib/volt/builder/css.ex +++ b/lib/volt/builder/css.ex @@ -1,6 +1,42 @@ defmodule Volt.Builder.CSS do @moduledoc false + def prepare_parts(css_parts, bundle_opts) do + css_parts + |> Enum.reduce_while({:ok, []}, fn part, {:ok, acc} -> + case prepare_part(part, bundle_opts) do + {:ok, prepared} -> {:cont, {:ok, [prepared | acc]}} + {:error, _} = error -> {:halt, error} + end + end) + |> case do + {:ok, reversed} -> + parts = Enum.reverse(reversed) + + {:ok, + %Volt.Builder.CSS.Prepared{ + code: Enum.map_join(parts, "\n", & &1.code), + assets: parts |> Enum.flat_map(& &1.assets) |> Enum.uniq(), + artifacts: Enum.flat_map(parts, & &1.artifacts) + }} + + {:error, _} = error -> + error + end + end + + def prepare_part({source_path, css}, bundle_opts) do + with {:ok, css} <- Volt.CSS.Imports.inline(css, source_path, bundle_opts) do + Volt.CSS.AssetURLRewriter.prepare(css, source_path, + prefix: Keyword.get(bundle_opts, :asset_url_prefix, Volt.Paths.prefix()), + root: Keyword.get(bundle_opts, :root) + ) + end + end + + def prepare_part(css, _bundle_opts), + do: {:ok, %Volt.Builder.CSS.Prepared{code: css, assets: [], artifacts: []}} + def bundle_parts(css_parts, fun) when is_function(fun, 1) do css_parts |> Enum.reduce_while({:ok, [], []}, fn css_part, {:ok, code_parts, assets} -> diff --git a/lib/volt/builder/css/prepared.ex b/lib/volt/builder/css/prepared.ex new file mode 100644 index 0000000..0eda5dd --- /dev/null +++ b/lib/volt/builder/css/prepared.ex @@ -0,0 +1,12 @@ +defmodule Volt.Builder.CSS.Prepared do + @moduledoc false + + @enforce_keys [:code, :assets, :artifacts] + defstruct [:code, :assets, :artifacts] + + @type t :: %__MODULE__{ + code: String.t(), + assets: [Volt.Builder.Asset.t()], + artifacts: [Volt.Builder.Artifact.t()] + } +end diff --git a/lib/volt/builder/collector/dependencies.ex b/lib/volt/builder/dependencies.ex similarity index 100% rename from lib/volt/builder/collector/dependencies.ex rename to lib/volt/builder/dependencies.ex diff --git a/lib/volt/builder/manifest_entry.ex b/lib/volt/builder/manifest_entry.ex index ce5aa16..bd5fb5f 100644 --- a/lib/volt/builder/manifest_entry.ex +++ b/lib/volt/builder/manifest_entry.ex @@ -20,11 +20,49 @@ defmodule Volt.Builder.ManifestEntry do assets: [String.t()] } + @doc "Return conflicting identities; identical shared entries are permitted." + def conflicts(left, right) do + for {key, entry} <- left, + Map.has_key?(right, key) and right[key] != entry, + do: key + end + def js(src, file, opts \\ []) do %__MODULE__{src: src, file: file, isEntry: Keyword.get(opts, :entry, false)} end def css(src, file, assets), do: %__MODULE__{src: src, file: file, assets: assets} - def asset(src, file), do: %__MODULE__{src: src, file: file} + + @doc "Prefix emitted paths, preserving imports and dynamicImports as manifest keys." + @spec prefix_paths(t(), String.t()) :: t() + def prefix_paths(%__MODULE__{} = entry, prefix) do + %{ + entry + | file: prefix_path(entry.file, prefix), + css: Enum.map(entry.css, &prefix_path(&1, prefix)), + assets: Enum.map(entry.assets, &prefix_path(&1, prefix)) + } + end + + @doc "Collect an entry's static stylesheet dependencies, dependencies first and deduplicated." + @spec stylesheets(%{String.t() => t()}, String.t()) :: [String.t()] + def stylesheets(manifest, key) do + {_seen, files} = collect_stylesheets(manifest, key, {MapSet.new(), []}) + files |> Enum.reverse() |> Enum.uniq() + end + + defp collect_stylesheets(manifest, key, {seen, files} = acc) do + if MapSet.member?(seen, key) do + acc + else + %__MODULE__{} = entry = Map.fetch!(manifest, key) + acc = {MapSet.put(seen, key), files} + {seen, files} = Enum.reduce(entry.imports, acc, &collect_stylesheets(manifest, &1, &2)) + {seen, Enum.reduce(entry.css, files, &[&1 | &2])} + end + end + + defp prefix_path(nil, _prefix), do: nil + defp prefix_path(path, prefix), do: Path.join(prefix, path) end diff --git a/lib/volt/builder/naming.ex b/lib/volt/builder/naming.ex index 645ed49..bb930f0 100644 --- a/lib/volt/builder/naming.ex +++ b/lib/volt/builder/naming.ex @@ -3,6 +3,11 @@ defmodule Volt.Builder.Naming do @windows_reserved ~w(CON PRN AUX NUL COM1 COM2 COM3 COM4 COM5 COM6 COM7 COM8 COM9 LPT1 LPT2 LPT3 LPT4 LPT5 LPT6 LPT7 LPT8 LPT9) + @spec hash(iodata()) :: String.t() + def hash(content) do + :crypto.hash(:sha256, content) |> Base.encode16(case: :lower) |> binary_part(0, 8) + end + @spec entry_name(String.t(), String.t() | nil) :: String.t() def entry_name(path, override \\ nil) def entry_name(_path, override) when is_binary(override), do: file_path(override) diff --git a/lib/volt/builder/output.ex b/lib/volt/builder/output.ex index 5b5f659..5ce655e 100644 --- a/lib/volt/builder/output.ex +++ b/lib/volt/builder/output.ex @@ -4,7 +4,12 @@ defmodule Volt.Builder.Output do alias Volt.Builder.{Naming, Rewriter, Writer} @doc "Bundle modules into a single in-memory JS bundle." - def bundle_single(entry, {js_files, css_parts, assets}, files, build_ctx) do + def bundle_single( + entry, + %Volt.Builder.Compiled{scripts: js_files, styles: css_parts, assets: assets}, + files, + build_ctx + ) do %{ outdir: outdir, bundle_opts: bundle_opts, @@ -55,7 +60,25 @@ defmodule Volt.Builder.Output do end @doc "Bundle modules into a single JS file and write output." - def build_single(entry, name, {js_files, css_parts, assets}, build_ctx) do + def build_single(entry, name, compiled, build_ctx) do + with {:ok, result, plan} <- prepare_single(entry, name, compiled, build_ctx), + :ok <- Writer.write_plan(build_ctx.outdir, plan) do + {:ok, result} + end + end + + @doc "Prepare one bundled entry and its stylesheet without writing output." + def prepare_single( + entry, + name, + %Volt.Builder.Compiled{ + scripts: js_files, + styles: css_parts, + assets: assets, + artifacts: asset_artifacts + }, + build_ctx + ) do %{ outdir: outdir, hash: hash, @@ -65,8 +88,6 @@ defmodule Volt.Builder.Output do asset_url_prefix: asset_url_prefix } = build_ctx - File.mkdir_p!(outdir) - js_files = Rewriter.rewrite_external_imports(js_files, ctx) entry_label = entry |> Path.basename() |> Naming.file_path() bundle_opts = Keyword.put(bundle_opts, :entry, entry_label) @@ -83,10 +104,18 @@ defmodule Volt.Builder.Output do Volt.PluginRunner.render_chunk(ctx.plugins, js_code, %{name: name, type: :entry}) js_filename = Writer.hashed_name(name, js_code, ".js", hash) - Writer.write_js(outdir, js_filename, js_code, js_sourcemap, hidden: sourcemap_hidden) + + js_artifacts = + Volt.Builder.Artifact.javascript(js_filename, js_code, js_sourcemap, + hidden: sourcemap_hidden + ) + css_opts = Keyword.put(bundle_opts, :asset_url_prefix, asset_url_prefix) - with {:ok, css_result} <- Writer.write_css(css_parts, outdir, name, hash, css_opts) do + with {:ok, css_result, css_plan} <- + Writer.prepare_css(css_parts, outdir, name, hash, css_opts), + {:ok, plan} <- + Volt.Builder.Plan.new(js_artifacts ++ css_plan.artifacts ++ asset_artifacts) do manifest = Writer.build_manifest(name, js_filename, css_result, assets) {:ok, @@ -96,8 +125,9 @@ defmodule Volt.Builder.Output do size: byte_size(js_code) }, css: css_result, + styles: List.wrap(css_result), manifest: manifest - }} + }, plan} end {:error, _} = error -> @@ -106,9 +136,23 @@ defmodule Volt.Builder.Output do end @doc "Bundle multiple ESM entries together and write Rolldown shared chunks." - def build_shared_entries( + def build_shared_entries(entries, compiled, modules, path_labels, build_ctx) do + with {:ok, result, plan} <- + prepare_shared_entries(entries, compiled, modules, path_labels, build_ctx), + :ok <- Writer.write_plan(build_ctx.outdir, plan) do + {:ok, result} + end + end + + @doc "Prepare shared ESM entries and associated artifacts without writing output." + def prepare_shared_entries( entries, - {js_files, css_parts, assets}, + %Volt.Builder.Compiled{ + scripts: js_files, + styles: css_parts, + assets: assets, + artifacts: asset_artifacts + }, modules, path_labels, build_ctx @@ -121,8 +165,6 @@ defmodule Volt.Builder.Output do asset_url_prefix: asset_url_prefix } = build_ctx - File.mkdir_p!(outdir) - js_files = Rewriter.rewrite_external_imports(js_files, ctx) bundle = @@ -144,8 +186,7 @@ defmodule Volt.Builder.Output do ) with {:ok, bundle_result} <- OXC.Bundle.run(bundle), - :ok <- write_shared_assets(bundle_result.outputs, outdir), - {:ok, css_results} <- + {:ok, css_results, css_artifacts} <- write_shared_css( css_parts, bundle_result.outputs, @@ -154,7 +195,7 @@ defmodule Volt.Builder.Output do bundle_opts, asset_url_prefix ) do - js_results = + prepared_js = bundle_result.outputs |> Enum.filter(&(&1.type in [:entry, :chunk])) |> Enum.map(fn output -> @@ -166,33 +207,60 @@ defmodule Volt.Builder.Output do type: output.type }) - Writer.write_js(outdir, output.file_name, code, output.sourcemap, - hidden: sourcemap_hidden - ) + artifacts = + Volt.Builder.Artifact.javascript(output.file_name, code, output.sourcemap, + hidden: sourcemap_hidden + ) - %Volt.Builder.OutputFile{ - path: Path.join(outdir, output.file_name), - size: byte_size(code), - chunk_id: output.file_name, - type: output.type - } + {%Volt.Builder.OutputFile{ + path: Path.join(outdir, output.file_name), + size: byte_size(code), + chunk_id: output.file_name, + type: output.type + }, artifacts} + end) + + js_results = Enum.map(prepared_js, &elem(&1, 0)) + + artifacts = + Enum.flat_map(prepared_js, &elem(&1, 1)) ++ + List.flatten(css_artifacts) ++ shared_assets(bundle_result.outputs) ++ asset_artifacts + + chunk_keys = + Map.new(Enum.filter(bundle_result.outputs, &(&1.type in [:entry, :chunk])), fn output -> + {output.file_name, + if(output.type == :entry, do: "#{output.name}.js", else: output.file_name)} end) manifest = bundle_result.outputs |> Enum.reduce(%{}, fn output, acc -> + output = + if output.type in [:entry, :chunk] do + %{ + output + | imports: manifest_imports(output.imports, chunk_keys), + dynamic_imports: manifest_imports(output.dynamic_imports, chunk_keys) + } + else + output + end + output_manifest_entry(output, css_results, assets, acc) end) |> Writer.add_asset_entries(assets) |> Writer.add_asset_entries(css_assets(css_results)) - {:ok, - %Volt.Builder.Result{ - js: Enum.filter(js_results, &(&1.type == :entry)), - css: nil, - manifest: manifest, - chunks: js_results - }} + with {:ok, plan} <- Volt.Builder.Plan.new(artifacts) do + {:ok, + %Volt.Builder.Result{ + js: Enum.filter(js_results, &(&1.type == :entry)), + css: nil, + styles: css_results |> Map.values() |> Enum.sort_by(& &1.path), + manifest: manifest, + chunks: js_results + }, plan} + end end end @@ -212,7 +280,7 @@ defmodule Volt.Builder.Output do defp shared_asset_file_names(false), do: "[name][extname]" defp write_shared_css([], _outputs, _modules, _outdir, _bundle_opts, _asset_url_prefix), - do: {:ok, %{}} + do: {:ok, %{}, []} defp write_shared_css(css_parts, outputs, modules, outdir, bundle_opts, asset_url_prefix) do label_to_output = shared_label_to_output(outputs) @@ -226,13 +294,18 @@ defmodule Volt.Builder.Output do css_parts |> Enum.group_by(fn {path, _css} -> Map.get(label_to_output, module_labels[path]) end) |> Enum.reject(fn {file_name, _parts} -> is_nil(file_name) end) - |> Enum.reduce_while({:ok, %{}}, fn {file_name, parts}, {:ok, acc} -> + |> Enum.reduce_while({:ok, %{}, []}, fn {file_name, parts}, {:ok, acc, artifacts} -> name = file_name |> Path.basename() |> Path.rootname() - case Writer.write_css(parts, outdir, name, false, css_opts) do - {:ok, nil} -> {:cont, {:ok, acc}} - {:ok, css_result} -> {:cont, {:ok, Map.put(acc, file_name, css_result)}} - {:error, _} = error -> {:halt, error} + case Writer.prepare_css(parts, outdir, name, false, css_opts) do + {:ok, nil, _plan} -> + {:cont, {:ok, acc, artifacts}} + + {:ok, css_result, plan} -> + {:cont, {:ok, Map.put(acc, file_name, css_result), [plan.artifacts | artifacts]}} + + {:error, _} = error -> + {:halt, error} end end) end @@ -249,17 +322,22 @@ defmodule Volt.Builder.Output do |> Map.new() end - defp write_shared_assets(outputs, outdir) do + defp shared_assets(outputs) do outputs |> Enum.filter(&(&1.type == :asset)) |> Enum.reject(&String.ends_with?(&1.file_name, ".map")) - |> Enum.each(fn output -> - path = Path.join(outdir, output.file_name) - File.mkdir_p!(Path.dirname(path)) - File.write!(path, output.source || "") + |> Enum.map(fn output -> + %Volt.Builder.Artifact{file: output.file_name, content: output.source || ""} end) + end - :ok + defp manifest_imports(imports, chunk_keys) do + Enum.flat_map(imports, fn reference -> + case Map.fetch(chunk_keys, reference) do + {:ok, key} -> [key] + :error -> [] + end + end) end defp output_manifest_entry(%{type: :asset} = output, _css_results, _assets, acc) do @@ -292,7 +370,26 @@ defmodule Volt.Builder.Output do defp maybe_add_entry_assets(entry, _type, _assets), do: entry @doc "Bundle modules into separate chunks based on the chunk graph." - def build_chunks(entry, name, {js_files, css_parts, assets}, {modules, dep_map}, build_ctx) do + def build_chunks(entry, name, compiled, graph_input, build_ctx) do + with {:ok, result, plan} <- prepare_chunks(entry, name, compiled, graph_input, build_ctx), + :ok <- Writer.write_plan(build_ctx.outdir, plan) do + {:ok, result} + end + end + + @doc "Prepare split chunks and their stylesheets without writing output." + def prepare_chunks( + entry, + name, + %Volt.Builder.Compiled{ + scripts: js_files, + styles: css_parts, + assets: assets, + artifacts: asset_artifacts + }, + {modules, dep_map}, + build_ctx + ) do %{ outdir: outdir, hash: hash, @@ -303,8 +400,6 @@ defmodule Volt.Builder.Output do asset_url_prefix: asset_url_prefix } = build_ctx - File.mkdir_p!(outdir) - graph = Volt.ChunkGraph.build(entry, modules, dep_map, manual_chunks: manual_chunks) js_map = Map.new(js_files) module_labels = Map.new(modules, fn {path, label, _source} -> {path, label} end) @@ -321,7 +416,7 @@ defmodule Volt.Builder.Output do ) do css_opts = Keyword.put(bundle_opts, :asset_url_prefix, asset_url_prefix) - with {:ok, css_results} <- + with {:ok, css_results, css_artifacts} <- write_chunk_css(css_parts, graph, outdir, name, hash, css_opts) do {chunk_url_map, processed_chunks} = finalize_chunk_urls( @@ -336,21 +431,30 @@ defmodule Volt.Builder.Output do dep_map ) - js_results = + prepared_js = Enum.map(processed_chunks, fn {chunk_id, {code, sourcemap}} -> chunk = graph.chunks[chunk_id] filename = chunk_url_map[chunk_id] - Writer.write_js(outdir, filename, code, sourcemap, hidden: sourcemap_hidden) - - %Volt.Builder.OutputFile{ - path: Path.join(outdir, filename), - size: byte_size(code), - chunk_id: chunk_id, - type: chunk.type - } + artifacts = + Volt.Builder.Artifact.javascript(filename, code, sourcemap, + hidden: sourcemap_hidden + ) + + {%Volt.Builder.OutputFile{ + path: Path.join(outdir, filename), + size: byte_size(code), + chunk_id: chunk_id, + type: chunk.type + }, artifacts} end) + js_results = Enum.map(prepared_js, &elem(&1, 0)) + + artifacts = + Enum.flat_map(prepared_js, &elem(&1, 1)) ++ + List.flatten(css_artifacts) ++ asset_artifacts + entry_js = Enum.find(js_results, &(&1.type == :entry)) || hd(js_results) entry_css = css_results[entry_js.chunk_id] @@ -381,13 +485,16 @@ defmodule Volt.Builder.Output do |> Writer.add_asset_entries(assets) |> Writer.add_asset_entries(css_assets(css_results)) - {:ok, - %Volt.Builder.Result{ - js: entry_js, - css: entry_css, - manifest: manifest, - chunks: js_results - }} + with {:ok, plan} <- Volt.Builder.Plan.new(artifacts) do + {:ok, + %Volt.Builder.Result{ + js: entry_js, + css: entry_css, + styles: css_results |> Map.values() |> Enum.sort_by(& &1.path), + manifest: manifest, + chunks: js_results + }, plan} + end end end end @@ -539,13 +646,18 @@ defmodule Volt.Builder.Output do defp write_chunk_css(css_parts, graph, outdir, name, hash, css_opts) do css_parts |> Enum.group_by(fn {path, _css} -> Map.get(graph.module_to_chunk, path, "entry") end) - |> Enum.reduce_while({:ok, %{}}, fn {chunk_id, parts}, {:ok, acc} -> + |> Enum.reduce_while({:ok, %{}, []}, fn {chunk_id, parts}, {:ok, acc, artifacts} -> chunk = graph.chunks[chunk_id] - case Writer.write_css(parts, outdir, chunk_output_name(chunk, name), hash, css_opts) do - {:ok, nil} -> {:cont, {:ok, acc}} - {:ok, css_result} -> {:cont, {:ok, Map.put(acc, chunk_id, css_result)}} - {:error, _} = error -> {:halt, error} + case Writer.prepare_css(parts, outdir, chunk_output_name(chunk, name), hash, css_opts) do + {:ok, nil, _plan} -> + {:cont, {:ok, acc, artifacts}} + + {:ok, css_result, plan} -> + {:cont, {:ok, Map.put(acc, chunk_id, css_result), [plan.artifacts | artifacts]}} + + {:error, _} = error -> + {:halt, error} end end) end diff --git a/lib/volt/builder/context/output.ex b/lib/volt/builder/output_context.ex similarity index 100% rename from lib/volt/builder/context/output.ex rename to lib/volt/builder/output_context.ex diff --git a/lib/volt/builder/output/file.ex b/lib/volt/builder/output_file.ex similarity index 90% rename from lib/volt/builder/output/file.ex rename to lib/volt/builder/output_file.ex index dfae82a..51987c6 100644 --- a/lib/volt/builder/output/file.ex +++ b/lib/volt/builder/output_file.ex @@ -10,7 +10,7 @@ defmodule Volt.Builder.OutputFile do @type t :: %__MODULE__{ path: String.t(), size: non_neg_integer(), - assets: [term()], + assets: [Volt.Builder.Asset.t()], chunk_id: String.t() | nil, type: :entry | :chunk | nil } diff --git a/lib/volt/builder/plan.ex b/lib/volt/builder/plan.ex new file mode 100644 index 0000000..bdda980 --- /dev/null +++ b/lib/volt/builder/plan.ex @@ -0,0 +1,69 @@ +defmodule Volt.Builder.Plan do + @moduledoc false + + alias Volt.Builder.Artifact + + @enforce_keys [:artifacts] + defstruct [:artifacts] + + @type t :: %__MODULE__{artifacts: [Artifact.t()]} + + @spec new([Artifact.t()]) :: {:ok, t()} | {:error, term()} + def new(artifacts) do + artifacts + |> Enum.reduce_while({:ok, %{}}, fn %Artifact{} = artifact, {:ok, files} -> + cond do + not safe_path?(artifact.file) -> + {:halt, {:error, {:invalid_output_path, artifact.file}}} + + Map.has_key?(files, artifact.file) and files[artifact.file] != artifact -> + {:halt, {:error, {:output_collision, artifact.file}}} + + true -> + {:cont, {:ok, Map.put(files, artifact.file, artifact)}} + end + end) + |> case do + {:ok, files} -> {:ok, %__MODULE__{artifacts: Enum.sort_by(Map.values(files), & &1.file)}} + {:error, _} = error -> error + end + end + + @doc "Validate manifest identities and emitted file references against a prepared plan." + def validate_manifest(%__MODULE__{artifacts: artifacts}, manifest) do + files = MapSet.new(artifacts, & &1.file) + + errors = + Enum.flat_map(manifest, fn {key, %Volt.Builder.ManifestEntry{} = entry} -> + missing_keys = + for reference <- entry.imports ++ entry.dynamicImports, + not Map.has_key?(manifest, reference), + do: {:missing_manifest_entry, key, reference} + + missing_files = + for file <- [entry.file | entry.css ++ entry.assets], + not MapSet.member?(files, file), + do: {:missing_artifact, key, file} + + missing_keys ++ missing_files + end) + |> Enum.uniq() + |> Enum.sort() + + case errors do + [] -> :ok + errors -> {:error, {:invalid_manifest, errors}} + end + end + + defp safe_path?(path) when is_binary(path) do + parts = Path.split(path) + + path != "" and Path.type(path) == :relative and + not String.contains?(path, ["\\", ":", <<0>>]) and + Enum.all?(parts, &(&1 not in [".", ".."])) and + Path.join(parts) == path + end + + defp safe_path?(_path), do: false +end diff --git a/lib/volt/builder/publication.ex b/lib/volt/builder/publication.ex new file mode 100644 index 0000000..7b00012 --- /dev/null +++ b/lib/volt/builder/publication.ex @@ -0,0 +1,98 @@ +defmodule Volt.Builder.Publication do + @moduledoc "Staged asset publication with manifest-last installation, not a whole-directory transaction." + + alias Volt.Builder.Plan + + @spec write(String.t(), Plan.t()) :: :ok | {:error, term()} + def write(outdir, %Plan{} = plan) do + root = Path.expand(outdir) + lock = {{__MODULE__, root}, self()} + :global.trans(lock, fn -> publish(root, plan) end, [node()]) + end + + defp publish(root, plan) do + with :ok <- validate_destinations(root, plan.artifacts), + :ok <- File.mkdir_p(Path.dirname(root)), + {:ok, stage} <- create_stage(root, 10) do + try do + with :ok <- stage_files(stage, plan.artifacts), + :ok <- validate_destinations(root, plan.artifacts) do + install(root, stage, plan.artifacts) + end + after + File.rm_rf(stage) + end + end + end + + defp create_stage(_root, 0), do: {:error, :staging_name_exhausted} + + defp create_stage(root, attempts) do + stage = + root <> ".volt-stage-" <> Base.url_encode64(:crypto.strong_rand_bytes(12), padding: false) + + case File.mkdir(stage) do + :ok -> {:ok, stage} + {:error, :eexist} -> create_stage(root, attempts - 1) + {:error, reason} -> {:error, {:staging_directory_failed, reason}} + end + end + + defp validate_destinations(root, artifacts) do + paths = + Enum.flat_map(artifacts, fn artifact -> + {_path, paths} = + Enum.reduce(Path.split(artifact.file), {root, [root]}, fn part, {parent, paths} -> + path = Path.join(parent, part) + {path, [path | paths]} + end) + + paths + end) + + paths + |> Enum.uniq() + |> Enum.reduce_while(:ok, fn path, :ok -> + case File.lstat(path) do + {:ok, %{type: :symlink}} -> {:halt, {:error, {:symlink_destination, path}}} + {:ok, _} -> {:cont, :ok} + {:error, reason} when reason in [:enoent, :enotdir] -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, {:destination_check_failed, path, reason}}} + end + end) + end + + defp stage_files(stage, artifacts) do + Enum.reduce_while(artifacts, :ok, fn artifact, :ok -> + path = Path.join(stage, artifact.file) + + case write_file(path, artifact.content) do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, {:staging_failed, artifact.file, reason}}} + end + end) + end + + defp write_file(path, content) do + with :ok <- File.mkdir_p(Path.dirname(path)), do: File.write(path, content) + end + + defp install(root, stage, artifacts) do + artifacts + |> Enum.sort_by(fn artifact -> + {Path.basename(artifact.file) == "manifest.json", artifact.file} + end) + |> Enum.reduce_while(:ok, fn artifact, :ok -> + destination = Path.join(root, artifact.file) + + result = + with :ok <- File.mkdir_p(Path.dirname(destination)), + do: File.rename(Path.join(stage, artifact.file), destination) + + case result do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, {:publication_failed, artifact.file, reason}}} + end + end) + end +end diff --git a/lib/volt/builder/result.ex b/lib/volt/builder/result.ex index 3dac361..3554b43 100644 --- a/lib/volt/builder/result.ex +++ b/lib/volt/builder/result.ex @@ -1,11 +1,12 @@ defmodule Volt.Builder.Result do @moduledoc "Production build result returned from `Volt.Builder`." - defstruct js: [], css: nil, manifest: %{}, chunks: [] + defstruct js: [], css: nil, styles: [], manifest: %{}, chunks: [] @type t :: %__MODULE__{ js: Volt.Builder.OutputFile.t() | [Volt.Builder.OutputFile.t()], css: Volt.Builder.OutputFile.t() | nil, + styles: [Volt.Builder.OutputFile.t()], manifest: %{String.t() => Volt.Builder.ManifestEntry.t()}, chunks: [Volt.Builder.OutputFile.t()] } diff --git a/lib/volt/builder/writer.ex b/lib/volt/builder/writer.ex index ee49d92..e58ec5d 100644 --- a/lib/volt/builder/writer.ex +++ b/lib/volt/builder/writer.ex @@ -2,59 +2,79 @@ defmodule Volt.Builder.Writer do @moduledoc "Writes production JavaScript, CSS, assets, sourcemaps, and manifests." def write_js(outdir, filename, code, sourcemap, opts \\ []) do - hidden = Keyword.get(opts, :hidden, false) + artifacts = Volt.Builder.Artifact.javascript(filename, code, sourcemap, opts) - code = - if sourcemap && !hidden do - code <> "\n//# sourceMappingURL=#{filename}.map\n" - else - code - end + {:ok, plan} = Volt.Builder.Plan.new(artifacts) + write_plan(outdir, plan) + end - File.write!(Path.join(outdir, filename), code) + @doc false + @spec write_plan(String.t(), Volt.Builder.Plan.t()) :: :ok | {:error, term()} + def write_plan(outdir, %Volt.Builder.Plan{} = plan) do + Volt.Builder.Publication.write(outdir, plan) + end - if sourcemap do - File.write!(Path.join(outdir, "#{filename}.map"), sourcemap) + def write_css(css_parts, outdir, name, hash, bundle_opts) do + with {:ok, result, plan} <- prepare_css(css_parts, outdir, name, hash, bundle_opts), + :ok <- write_plan(outdir, plan) do + {:ok, result} end end - def write_css([], _outdir, _name, _hash, _bundle_opts), do: {:ok, nil} - - def write_css(css_parts, outdir, name, hash, bundle_opts) do - with {:ok, %{code: css_code, assets: assets}} <- - Volt.Builder.CSS.rewrite_parts(css_parts, outdir, bundle_opts), - {:ok, css_code} <- Volt.Builder.CSS.compile(css_code, bundle_opts) do - css_filename = hashed_name(name, css_code, ".css", hash) - css_path = Path.join(outdir, css_filename) - File.write!(css_path, css_code) - {:ok, %Volt.Builder.OutputFile{path: css_path, size: byte_size(css_code), assets: assets}} + def prepare_css([], _outdir, _name, _hash, _bundle_opts), + do: {:ok, nil, %Volt.Builder.Plan{artifacts: []}} + + def prepare_css(css_parts, outdir, name, hash, bundle_opts) do + with {:ok, prepared} <- Volt.Builder.CSS.prepare_parts(css_parts, bundle_opts), + {:ok, css_code} <- Volt.Builder.CSS.compile(prepared.code, bundle_opts), + css_filename = hashed_name(name, css_code, ".css", hash), + {:ok, plan} <- + Volt.Builder.Plan.new([ + %Volt.Builder.Artifact{file: css_filename, content: css_code} | prepared.artifacts + ]) do + {:ok, + %Volt.Builder.OutputFile{ + path: Path.join(outdir, css_filename), + size: byte_size(css_code), + assets: prepared.assets + }, plan} end end def build_style_entry(name, css_code, outdir, hash, source_path \\ nil, bundle_opts \\ []) do - File.mkdir_p!(outdir) + with {:ok, result, plan} <- + prepare_style_entry(name, css_code, outdir, hash, source_path, bundle_opts), + :ok <- write_plan(outdir, plan) do + {:ok, result} + end + end - with {:ok, %{code: css_code, assets: assets}} <- - Volt.Builder.CSS.rewrite_part({source_path, css_code}, outdir, bundle_opts), - {:ok, css_code} <- Volt.Builder.CSS.compile(css_code, bundle_opts) do - css_filename = hashed_name(name, css_code, ".css", hash) + @doc "Prepare a standalone stylesheet and its referenced assets without writing files." + def prepare_style_entry(name, css_code, outdir, hash, source_path, bundle_opts) do + with {:ok, prepared} <- + Volt.Builder.CSS.prepare_part({source_path, css_code}, bundle_opts), + {:ok, css_code} <- Volt.Builder.CSS.compile(prepared.code, bundle_opts), + css_filename = hashed_name(name, css_code, ".css", hash), + {:ok, plan} <- + Volt.Builder.Plan.new([ + %Volt.Builder.Artifact{file: css_filename, content: css_code} | prepared.artifacts + ]) do css_path = Path.join(outdir, css_filename) css_result = %Volt.Builder.OutputFile{ path: css_path, size: byte_size(css_code), - assets: assets + assets: prepared.assets } - File.write!(css_path, css_code) - manifest = %{ - "#{name}.css" => %{ - "file" => css_filename, - "src" => "#{name}.css", - "assets" => css_assets(css_filename, css_result) - } + "#{name}.css" => + Volt.Builder.ManifestEntry.css( + "#{name}.css", + css_filename, + css_assets(css_filename, css_result) + ) } |> add_asset_entries(css_result.assets) @@ -62,8 +82,9 @@ defmodule Volt.Builder.Writer do %Volt.Builder.Result{ js: [], css: css_result, + styles: [css_result], manifest: manifest - }} + }, plan} end end @@ -106,7 +127,7 @@ defmodule Volt.Builder.Writer do end def hashed_name(name, content, ext, true) do - "#{name}-#{Volt.Format.content_hash(content)}#{ext}" + "#{name}-#{Volt.Builder.Naming.hash(content)}#{ext}" end def hashed_name(name, _content, ext, false), do: "#{name}#{ext}" @@ -118,8 +139,7 @@ defmodule Volt.Builder.Writer do def asset_files(assets) do assets |> Enum.map(fn - %{file: file} -> file - %{"file" => file} -> file + %Volt.Builder.Asset{file: file} -> file file when is_binary(file) -> file end) |> Enum.uniq() @@ -127,10 +147,7 @@ defmodule Volt.Builder.Writer do def add_asset_entries(manifest, assets) do Enum.reduce(assets, manifest, fn - %{src: src, file: file}, acc -> - Map.put_new(acc, src, Volt.Builder.ManifestEntry.asset(src, file)) - - %{"src" => src, "file" => file}, acc -> + %Volt.Builder.Asset{src: src, file: file}, acc -> Map.put_new(acc, src, Volt.Builder.ManifestEntry.asset(src, file)) _file, acc -> diff --git a/lib/volt/cache.ex b/lib/volt/cache.ex index 13e702d..6111ef7 100644 --- a/lib/volt/cache.ex +++ b/lib/volt/cache.ex @@ -12,6 +12,9 @@ defmodule Volt.Cache do @type cache_entry :: %{mtime: integer(), entry: entry()} + defp location(%Volt.Dev.Session.Tables{cache: table}, path), do: {table, path} + defp location(session, path), do: {@table, {session, path}} + @doc "Create the cache ETS table. Called once from Application.start/2." @spec create_table :: :ok def create_table do @@ -21,44 +24,53 @@ defmodule Volt.Cache do @doc "Look up a cached entry. Returns `nil` on miss." @spec get(String.t(), integer()) :: entry() | nil - def get(path, mtime) do - case :ets.lookup(@table, path) do - [{^path, %{mtime: ^mtime, entry: entry}}] -> entry + def get(path, mtime, session \\ :default) do + {table, key} = location(session, path) + + case :ets.lookup(table, key) do + [{^key, %{mtime: ^mtime, entry: entry}}] -> entry _ -> nil end end @doc "Look up any cached entry for a file path regardless of mtime." @spec get_file(String.t()) :: entry() | nil - def get_file(path) do - case :ets.lookup(@table, path) do - [{^path, %{entry: entry}}] -> entry + def get_file(path, session \\ :default) do + {table, key} = location(session, path) + + case :ets.lookup(table, key) do + [{^key, %{entry: entry}}] -> entry [] -> nil end end @doc "Store a compiled entry." @spec put(String.t(), integer(), entry()) :: :ok - def put(path, mtime, entry) do - :ets.insert(@table, {path, %{mtime: mtime, entry: entry}}) + def put(path, mtime, entry, session \\ :default) do + {table, key} = location(session, path) + :ets.insert(table, {key, %{mtime: mtime, entry: entry}}) :ok end @doc "Evict the entry for a cache key." @spec evict(String.t()) :: :ok - def evict(key) do - :ets.delete(@table, key) + def evict(key, session \\ :default) do + {table, key} = location(session, key) + :ets.delete(table, key) :ok end @doc "Evict all cache entries derived from a file path, including variant keys like `path <> \"?import\"`." @spec evict_file(String.t()) :: :ok - def evict_file(path) do - evict(path) - evict(Volt.URL.append_query(path, "import")) + def evict_file(path, session \\ :default) do + evict(path, session) + evict(Volt.URL.append_query(path, "import"), session) :ok end + @doc "Clear entries belonging to one development session." + def clear_session(session), do: Volt.ETS.clear_session(@table, session) + @doc "Clear all cached entries." @spec clear :: :ok def clear do diff --git a/lib/volt/config/build.ex b/lib/volt/config/build.ex index 1bbf77a..7868d4f 100644 --- a/lib/volt/config/build.ex +++ b/lib/volt/config/build.ex @@ -3,7 +3,9 @@ defmodule Volt.Config.Build do defstruct entry: Volt.Paths.entry(), outdir: Volt.Paths.static(), + output_layout: :split, public_dir: false, + assets_dir: "", target: :es2020, minify: true, sourcemap: true, diff --git a/lib/volt/config/tailwind.ex b/lib/volt/config/tailwind.ex new file mode 100644 index 0000000..d1f2ea3 --- /dev/null +++ b/lib/volt/config/tailwind.ex @@ -0,0 +1,42 @@ +defmodule Volt.Config.Tailwind do + @moduledoc "Normalized Tailwind CSS root configuration." + + @enforce_keys [:name, :sources] + defstruct [:css, :name, :sources, :dev_url] + + @type source :: %{ + required(:base) => String.t(), + required(:pattern) => String.t(), + optional(:negated) => boolean() + } + @type t :: %__MODULE__{ + css: String.t() | nil, + name: String.t(), + sources: [source()], + dev_url: String.t() + } + + @spec enabled?(keyword() | boolean() | nil) :: boolean() + def enabled?(true), do: true + def enabled?(config), do: is_list(config) and config != [] + + @spec new(keyword() | boolean() | nil, keyword()) :: t() + def new(config, overrides \\ []) + def new(config, overrides) when config in [nil, false, true], do: new([], overrides) + + def new(config, overrides) when is_list(config) do + css = overrides[:css] || config[:css] + name = overrides[:name] || config[:name] || entry_name(css) + + %__MODULE__{ + css: if(css, do: Path.expand(css)), + name: name, + sources: overrides[:sources] || config[:sources] || [], + dev_url: overrides[:dev_url] || config[:dev_url] || default_dev_url(name) + } + end + + defp entry_name(nil), do: "app" + defp entry_name(css), do: css |> Path.basename() |> Path.rootname() + defp default_dev_url(name), do: "/assets/css/#{name}.css" +end diff --git a/lib/volt/css/asset_url_rewriter.ex b/lib/volt/css/asset_url_rewriter.ex index a18d448..0e5643c 100644 --- a/lib/volt/css/asset_url_rewriter.ex +++ b/lib/volt/css/asset_url_rewriter.ex @@ -8,7 +8,7 @@ defmodule Volt.CSS.AssetURLRewriter do @type rewrite_result :: {:ok, String.t()} | {:error, term()} @type rewrite_assets_result :: - {:ok, %{code: String.t(), assets: [String.t() | map()]}} | {:error, term()} + {:ok, %{code: String.t(), assets: [Volt.Builder.Asset.t()]}} | {:error, term()} @doc "Rewrite relative CSS asset URLs to hashed output URLs." @spec rewrite(String.t(), String.t() | nil, String.t(), keyword()) :: rewrite_result() @@ -26,26 +26,62 @@ defmodule Volt.CSS.AssetURLRewriter do def rewrite_with_assets(css, nil, _outdir, _opts), do: {:ok, %{code: css, assets: []}} def rewrite_with_assets(css, source_path, outdir, opts) do + with {:ok, result} <- prepare(css, source_path, opts) do + Volt.Assets.write_artifacts(result.artifacts, outdir) + + {:ok, %{code: result.code, assets: result.assets}} + end + end + + @doc false + def prepare(css, source_path, opts \\ []) + + def prepare(css, nil, _opts), + do: {:ok, %Volt.Builder.CSS.Prepared{code: css, assets: [], artifacts: []}} + + def prepare(css, source_path, opts) do prefix = Keyword.get(opts, :prefix, Volt.Paths.prefix()) - with {:ok, code, {assets, _emitted}} <- + with {:ok, code, {assets, emitted}} <- rewrite_urls(css, [filename: source_path], {[], %{}}, fn url, state -> - rewrite_build_url(url, state, source_path, outdir, prefix, opts) + rewrite_build_url(url, state, source_path, prefix, opts) end) do - {:ok, %{code: code, assets: Enum.reverse(assets)}} + artifacts = + emitted + |> Enum.map(fn {_path, {_file, _asset, artifact}} -> artifact end) + |> Enum.sort_by(& &1.file) + + {:ok, + %Volt.Builder.CSS.Prepared{code: code, assets: Enum.reverse(assets), artifacts: artifacts}} else {:error, reason} -> {:error, {:css_parse_failed, reason}} end end + @doc "Rebase relative asset URLs before an imported stylesheet loses its source location." + def rebase(css, source_path, output_base) do + Vize.CSS.rewrite_urls(css, [filename: source_path], fn url -> + uri = URI.parse(url) + + if rewrite_candidate?(url) and Volt.Assets.asset?(uri.path) do + absolute = Path.expand(uri.path, Path.dirname(source_path)) + relative = Path.relative_to(absolute, output_base, force: true) + {:rewrite, append_suffix(relative, uri)} + else + :keep + end + end) + end + @doc "Rewrite relative CSS asset URLs to dev-server URLs without copying files." @spec rewrite_dev(String.t(), String.t() | nil, String.t(), String.t()) :: rewrite_result() - def rewrite_dev(css, nil, _root, _prefix), do: {:ok, css} + def rewrite_dev(css, source_path, root, prefix, opts \\ []) + def rewrite_dev(css, nil, _root, _prefix, _opts), do: {:ok, css} - def rewrite_dev(css, source_path, root, prefix) do + def rewrite_dev(css, source_path, root, prefix, opts) do with {:ok, css} <- Vize.CSS.rewrite_urls(css, [filename: source_path], fn url -> - case dev_url(url, source_path, root, prefix) do + case dev_url(url, source_path, root, prefix, opts) do {:ok, ^url} -> :keep {:ok, rewritten} -> {:rewrite, rewritten} end @@ -56,8 +92,8 @@ defmodule Volt.CSS.AssetURLRewriter do end end - defp rewrite_build_url(url, {assets, emitted}, source_path, outdir, prefix, opts) do - case build_url(url, source_path, outdir, prefix, emitted, opts) do + defp rewrite_build_url(url, {assets, emitted}, source_path, prefix, opts) do + case build_url(url, source_path, prefix, emitted, opts) do {:ok, ^url, emitted, _asset} -> {:keep, {assets, emitted}} @@ -67,13 +103,13 @@ defmodule Volt.CSS.AssetURLRewriter do end end - defp build_url(url, source_path, outdir, prefix, emitted, opts) do + defp build_url(url, source_path, prefix, emitted, opts) do if rewrite_candidate?(url) do uri = URI.parse(url) asset_path = Path.expand(uri.path || "", Path.dirname(source_path)) if Volt.Assets.asset?(asset_path) and File.regular?(asset_path) do - {filename, asset, emitted} = emitted_filename(asset_path, outdir, emitted, opts) + {filename, asset, emitted} = emitted_filename(asset_path, emitted, opts) {:ok, append_suffix(Volt.URL.join(prefix, filename), uri), emitted, asset} else {:ok, url, emitted, nil} @@ -83,15 +119,23 @@ defmodule Volt.CSS.AssetURLRewriter do end end - defp dev_url(url, source_path, root, prefix) do + defp dev_url(url, source_path, root, prefix, opts) do if rewrite_candidate?(url) do uri = URI.parse(url) asset_path = Path.expand(uri.path || "", Path.dirname(source_path)) - if Volt.Assets.asset?(asset_path) and File.regular?(asset_path) and - Volt.Path.inside?(asset_path, root) do - relative = Path.relative_to(asset_path, root) - {:ok, append_suffix(Volt.URL.join(prefix, relative), uri)} + if Volt.Assets.asset?(asset_path) and File.regular?(asset_path) do + cond do + Volt.Path.inside?(asset_path, root) -> + relative = Path.relative_to(asset_path, root) + {:ok, append_suffix(Volt.URL.join(prefix, relative), uri)} + + grant = Keyword.get(opts, :grant_asset) -> + {:ok, append_suffix(grant.(asset_path), uri)} + + true -> + {:ok, url} + end else {:ok, url} end @@ -100,15 +144,16 @@ defmodule Volt.CSS.AssetURLRewriter do end end - defp emitted_filename(asset_path, outdir, emitted, opts) do + defp emitted_filename(asset_path, emitted, opts) do case Map.fetch(emitted, asset_path) do - {:ok, {filename, asset}} -> + {:ok, {filename, asset, _artifact}} -> {filename, asset, emitted} :error -> - {:ok, filename} = Volt.Assets.copy_hashed(asset_path, outdir) + artifact = Volt.Assets.prepare_hashed(asset_path) + filename = artifact.file asset = Volt.Assets.manifest_asset(asset_path, filename, root: Keyword.get(opts, :root)) - {filename, asset, Map.put(emitted, asset_path, {filename, asset})} + {filename, asset, Map.put(emitted, asset_path, {filename, asset, artifact})} end end diff --git a/lib/volt/css/imports.ex b/lib/volt/css/imports.ex index 43853f7..16e728f 100644 --- a/lib/volt/css/imports.ex +++ b/lib/volt/css/imports.ex @@ -36,6 +36,8 @@ defmodule Volt.CSS.Imports do {:ok, imported_css} <- File.read(resolved), {:ok, imported_css} <- do_inline(imported_css, resolved, opts, MapSet.put(seen, resolved)), + {:ok, imported_css} <- + Volt.CSS.AssetURLRewriter.rebase(imported_css, resolved, Path.dirname(source_path)), {:ok, {rule_start, rule_end}} <- import_rule_bounds(css, dependency) do {:ok, replace_range(css, rule_start, rule_end, imported_css <> "\n")} else diff --git a/lib/volt/dev.ex b/lib/volt/dev.ex index c3ef3dc..d858639 100644 --- a/lib/volt/dev.ex +++ b/lib/volt/dev.ex @@ -12,7 +12,7 @@ defmodule Volt.Dev do def ensure_watcher(opts) when is_list(opts) do result = if dev_supervision_started?() do - start_watcher(opts) + start(opts) else {:error, :not_started} end @@ -26,23 +26,131 @@ defmodule Volt.Dev do end end - defp start_watcher(opts) do - {id, opts} = Keyword.pop(opts, :id, :default) - key = {:watcher, id, opts |> Keyword.fetch!(:root) |> Path.expand()} + @doc "Start a supervised generation with a state owner and watcher." + def start_supervised_session(session, opts) when session != :default do + watcher_opts = opts |> Keyword.put(:session, session) |> Keyword.put(:name, nil) + Volt.Dev.Session.Supervisor.start_link(identity: session, watcher: watcher_opts) + end + + @doc "Start or reuse a managed watcher, rejecting conflicting configuration." + def start(opts) do + session = session_identity(opts) + root = opts |> Keyword.fetch!(:root) |> Path.expand() + key = {:session, session} + opts = normalize_options(Keyword.put(opts, :session, session), root) + + :global.trans( + {{__MODULE__, key}, self()}, + fn -> + Volt.Dev.Session.Call.run(fn -> start_watcher(key, opts) end) + end, + [node()] + ) + end + + defp start_watcher({:session, session} = key, opts) do name = {:via, Registry, {@registry, key}} - case Registry.lookup(@registry, key) do - [{pid, _}] -> - {:ok, pid} + result = + case GenServer.whereis(name) do + nil -> + spec = + Supervisor.child_spec( + {Volt.Dev.Session.Supervisor, + [name: name, identity: session, watcher: Keyword.put(opts, :name, nil)]}, + restart: :transient + ) + + DynamicSupervisor.start_child(@supervisor, spec) + + pid -> + {:ok, pid} + end + case result do + {:ok, supervisor} -> verify_session(supervisor, opts) + {:error, {:already_started, supervisor}} -> verify_session(supervisor, opts) + error -> error + end + end + + @doc "Normalize omitted session identity from profile and asset root." + def session_identity(opts) do + case Keyword.get(opts, :session, :default) do + :default -> + {:managed, Keyword.get(opts, :id, :default), Path.expand(Keyword.fetch!(opts, :root))} + + session -> + session + end + end + + defp verify_session(supervisor, opts) do + case Enum.find(Supervisor.which_children(supervisor), fn {id, _, _, _} -> + id == Volt.Dev.Session.Watcher + end) do + {_, pid, _, _} when is_pid(pid) -> verify_configuration(supervisor, pid, opts) + _ -> {:error, :session_restarting} + end + end + + @doc "Read CSS from the worker owned by a managed session." + def stylesheet(session) do + case Registry.lookup(@registry, {:session, session}) do + [{supervisor, _}] -> Volt.Dev.Session.Supervisor.stylesheet(supervisor) + [] -> {:error, :session_not_started} + end + end + + @doc "Resolve the current owned generation for an explicitly identified session." + def tables(session) when session != :default do + case Registry.lookup(@registry, {:session, session}) do + [{supervisor, _}] -> Volt.Dev.Session.Supervisor.tables(supervisor) + [] -> {:error, :session_not_started} + end + end + + @doc "Stop an explicitly identified session and clear its compilation state." + def stop(:default), do: {:error, :explicit_session_required} + + def stop(session) do + :global.trans({{__MODULE__, {:session, session}}, self()}, fn -> stop_session(session) end, [ + node() + ]) + end + + defp stop_session(session) do + case Registry.lookup(@registry, {:session, session}) do [] -> - opts = Keyword.put(opts, :name, name) + :ok - case DynamicSupervisor.start_child(@supervisor, {Volt.Watcher, opts}) do - {:error, {:already_started, pid}} -> {:ok, pid} + [{pid, _}] -> + case DynamicSupervisor.terminate_child(@supervisor, pid) do + {:error, :not_found} -> :ok result -> result end end + + Volt.Dev.Cleanup.run(session) + end + + defp normalize_options(opts, root) do + opts + |> Keyword.delete(:id) + |> Keyword.put(:root, root) + |> Keyword.put_new(:session, :default) + |> Keyword.update(:watch_dirs, [], &Enum.map(&1, fn path -> Path.expand(path) end)) + |> Keyword.update(:reload_dirs, [], &Enum.map(&1, fn path -> Path.expand(path) end)) + end + + defp verify_configuration(supervisor, pid, opts) do + signature = opts |> Keyword.drop([:name, :managed_key]) |> Map.new() + + with %Volt.Dev.Session.Tables{} = tables <- Volt.Dev.Session.Supervisor.tables(supervisor) do + if Volt.Dev.Session.State.configuration_matches?(tables.owner, signature), + do: {:ok, pid}, + else: {:error, :session_configuration_conflict} + end end defp dev_supervision_started? do diff --git a/lib/volt/dev/assets.ex b/lib/volt/dev/assets.ex new file mode 100644 index 0000000..333f922 --- /dev/null +++ b/lib/volt/dev/assets.ex @@ -0,0 +1,34 @@ +defmodule Volt.Dev.Assets do + @moduledoc "Session-owned URL grants for assets resolved from project stylesheets." + + @table :volt_dev_assets + @prefix "/@volt/assets/" + + def create_table do + :ets.new(@table, [:named_table, :set, :public, read_concurrency: true]) + :ok + end + + @doc "Grant a browser URL to an already resolved asset; never accepts browser-supplied paths." + def register(path, session) do + path = Path.expand(path) + id = :crypto.hash(:sha256, path) |> Base.encode16(case: :lower) + {table, key} = location(session, id) + :ets.insert(table, {key, path}) + @prefix <> id + end + + def fetch(id, session) do + {table, key} = location(session, id) + + case :ets.lookup(table, key) do + [{^key, path}] -> {:ok, path} + [] -> :error + end + end + + def clear_session(session), do: Volt.ETS.clear_session(@table, session) + + defp location(%Volt.Dev.Session.Tables{assets: table}, id), do: {table, id} + defp location(session, id), do: {@table, {session, id}} +end diff --git a/lib/volt/dev/cleanup.ex b/lib/volt/dev/cleanup.ex new file mode 100644 index 0000000..1613654 --- /dev/null +++ b/lib/volt/dev/cleanup.ex @@ -0,0 +1,14 @@ +defmodule Volt.Dev.Cleanup do + @moduledoc "Cleanup of session-scoped compilation and dependency state." + + def run(session) do + Volt.Dev.Assets.clear_session(session) + Volt.Tailwind.Supervisor.release_runtime(session) + Volt.Cache.clear_session(session) + Volt.HMR.ImportGraph.clear_session(session) + Volt.HMR.GlobGraph.clear_session(session) + Volt.HMR.StyleGraph.clear_session(session) + Volt.HMR.ModuleGraph.clear_session(session) + :ok + end +end diff --git a/lib/volt/dev/session/call.ex b/lib/volt/dev/session/call.ex new file mode 100644 index 0000000..0bb8694 --- /dev/null +++ b/lib/volt/dev/session/call.ex @@ -0,0 +1,13 @@ +defmodule Volt.Dev.Session.Call do + @moduledoc "Translate disappearing session processes into a retryable lifecycle result." + + def run(fun) do + fun.() + catch + :exit, {reason, {GenServer, :call, _}} when reason in [:noproc, :normal, :shutdown] -> + {:error, :session_restarting} + + :exit, {{:shutdown, _}, {GenServer, :call, _}} -> + {:error, :session_restarting} + end +end diff --git a/lib/volt/dev/session/state.ex b/lib/volt/dev/session/state.ex new file mode 100644 index 0000000..8d730fd --- /dev/null +++ b/lib/volt/dev/session/state.ex @@ -0,0 +1,42 @@ +defmodule Volt.Dev.Session.State do + @moduledoc "Owns the compilation tables for one development-session generation." + + use GenServer + alias Volt.Dev.Session.Tables + + defstruct [:tables, :configuration] + + def start_link(opts) do + {configuration, opts} = Keyword.pop(opts, :configuration) + GenServer.start_link(__MODULE__, configuration, opts) + end + + def configuration_matches?(server, signature), + do: GenServer.call(server, {:configuration_matches, signature}) + + def tables(server), do: GenServer.call(server, :tables) + + def attach_stylesheet(server, worker), do: GenServer.call(server, {:attach_stylesheet, worker}) + + @impl true + def init(configuration) do + tables = + Map.new([:cache, :imports, :globs, :styles, :modules, :assets], fn kind -> + {kind, :ets.new(kind, [:set, :public, read_concurrency: true])} + end) + + tables = struct!(Tables, Map.merge(tables, %{owner: self(), generation: make_ref()})) + {:ok, %__MODULE__{tables: tables, configuration: configuration}} + end + + @impl true + def handle_call(:tables, _from, state), do: {:reply, state.tables, state} + + def handle_call({:configuration_matches, signature}, _from, state), + do: {:reply, state.configuration == signature, state} + + def handle_call({:attach_stylesheet, worker}, _from, state) do + tables = %{state.tables | stylesheet_worker: worker} + {:reply, tables, %{state | tables: tables}} + end +end diff --git a/lib/volt/dev/session/supervisor.ex b/lib/volt/dev/session/supervisor.ex new file mode 100644 index 0000000..0123cee --- /dev/null +++ b/lib/volt/dev/session/supervisor.ex @@ -0,0 +1,69 @@ +defmodule Volt.Dev.Session.Supervisor do + @moduledoc "Supervision boundary for a development session's state and dependent services." + + use Supervisor + + def start_link(opts) do + {name, opts} = Keyword.pop(opts, :name) + Supervisor.start_link(__MODULE__, opts, if(name, do: [name: name], else: [])) + end + + @doc "Read the current generation from a running session subtree." + def tables(supervisor), do: Volt.Dev.Session.Call.run(fn -> read_tables(supervisor) end) + + defp read_tables(supervisor) do + case Enum.find(Supervisor.which_children(supervisor), fn {id, _, _, _} -> + id == Volt.Dev.Session.State + end) do + {_, pid, _, _} when is_pid(pid) -> Volt.Dev.Session.State.tables(pid) + _ -> {:error, :session_restarting} + end + end + + @doc "Read the session's last successful stylesheet." + def stylesheet(supervisor), do: Volt.Dev.Session.Call.run(fn -> read_stylesheet(supervisor) end) + + defp read_stylesheet(supervisor) do + case Enum.find(Supervisor.which_children(supervisor), fn {id, _, _, _} -> + id == Volt.Tailwind.Worker + end) do + {_, pid, _, _} when is_pid(pid) -> Volt.Tailwind.Worker.stylesheet(pid) + _ -> {:error, :session_restarting} + end + end + + @impl true + def init(opts) do + # State must start first. Its replacement invalidates every dependent child's handles. + children = + case Keyword.fetch(opts, :watcher) do + {:ok, watcher_opts} -> + identity = Keyword.fetch!(opts, :identity) + owner_name = {:via, Registry, {Volt.Dev.WatcherRegistry, {:state_owner, identity}}} + + runtime_name = {:via, Registry, {Volt.Dev.WatcherRegistry, {:compiler, identity}}} + + worker_name = {:via, Registry, {Volt.Dev.WatcherRegistry, {:stylesheet, identity}}} + + [ + {Volt.Dev.Session.State, + name: owner_name, + configuration: watcher_opts |> Keyword.drop([:name, :managed_key]) |> Map.new()}, + {Volt.Tailwind.Runtime, name: runtime_name}, + {Volt.Tailwind.Worker, + name: worker_name, key: {:session, identity}, runtime: runtime_name}, + {Volt.Dev.Session.Watcher, + watcher_opts + |> Keyword.put(:tailwind_outdir, Keyword.get(watcher_opts, :tailwind_sink)) + |> Keyword.put(:state_owner, owner_name) + |> Keyword.put(:tailwind_worker, worker_name) + |> Keyword.put(:tailwind_runtime, runtime_name)} + ] + + :error -> + [{Volt.Dev.Session.State, []} | Keyword.get(opts, :children, [])] + end + + Supervisor.init(children, strategy: :rest_for_one, auto_shutdown: :any_significant) + end +end diff --git a/lib/volt/dev/session/tables.ex b/lib/volt/dev/session/tables.ex new file mode 100644 index 0000000..065abcf --- /dev/null +++ b/lib/volt/dev/session/tables.ex @@ -0,0 +1,6 @@ +defmodule Volt.Dev.Session.Tables do + @moduledoc "Generation-specific handles to ETS tables owned by a development session." + + @enforce_keys [:owner, :generation, :cache, :imports, :globs, :styles, :modules, :assets] + defstruct [{:stylesheet_worker, nil} | @enforce_keys] +end diff --git a/lib/volt/dev/session/watcher.ex b/lib/volt/dev/session/watcher.ex new file mode 100644 index 0000000..39d0205 --- /dev/null +++ b/lib/volt/dev/session/watcher.ex @@ -0,0 +1,20 @@ +defmodule Volt.Dev.Session.Watcher do + @moduledoc "Starts a watcher using the current session-owned table handles." + + def start_link(opts) do + {state_owner, opts} = Keyword.pop!(opts, :state_owner) + worker = GenServer.whereis(Keyword.fetch!(opts, :tailwind_worker)) + tables = Volt.Dev.Session.State.attach_stylesheet(state_owner, worker) + + Volt.Watcher.start_link(Keyword.put(opts, :tables, tables)) + end + + def child_spec(opts) do + %{ + id: __MODULE__, + start: {__MODULE__, :start_link, [opts]}, + restart: :transient, + significant: true + } + end +end diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index 0a2fbd2..a9326fe 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -54,12 +54,27 @@ defmodule Volt.DevServer do module_types = config.module_types tailwind_config = Config.tailwind(profile) + tailwind_root = + if Volt.Config.Tailwind.enabled?(tailwind_config), + do: Volt.Config.Tailwind.new(tailwind_config) + prebundle_vendor(expanded_root, node_modules, plugins, config.resolve_dirs, module_types) - watcher_opts = + session = if server_config.watch do + Volt.Dev.session_identity( + root: expanded_root, + id: profile || :default, + session: Keyword.get(opts, :session, :default) + ) + else + Keyword.get(opts, :session, :default) + end + + watcher_opts = + if server_config.watch and is_nil(Keyword.get(opts, :session_supervisor)) do watch_dirs = - if tailwind_config != [] and server_config.watch_dirs == [] do + if tailwind_root && server_config.watch_dirs == [] do [Volt.Paths.lib()] else server_config.watch_dirs @@ -67,12 +82,17 @@ defmodule Volt.DevServer do [ id: profile || :default, + session: session, root: expanded_root, watch_dirs: watch_dirs, reload_dirs: server_config.reload_dirs, watch_ignored: server_config.watch_ignored, - tailwind: tailwind_config != [], - tailwind_css: tailwind_config[:css], + tailwind_key: tailwind_key(profile, tailwind_root), + tailwind: not is_nil(tailwind_root), + tailwind_css: tailwind_root && tailwind_root.css, + tailwind_name: tailwind_root && tailwind_root.name, + tailwind_sources: tailwind_root && tailwind_root.sources, + tailwind_url: tailwind_root && tailwind_root.dev_url, tailwind_outdir: Path.join(to_string(config.outdir), "css"), target: config.target, import_source: config.import_source, @@ -103,19 +123,152 @@ defmodule Volt.DevServer do define: Volt.Env.define(mode: "development", root: File.cwd!(), env_prefix: config.env_prefix), hmr_timeout: server_config.hmr_timeout, + stylesheet_url: tailwind_root && tailwind_root.dev_url, + stylesheet_source: tailwind_root && tailwind_root.css, + session_supervisor: Keyword.get(opts, :session_supervisor), + tables: Keyword.get(opts, :tables), + session: session, watcher_opts: watcher_opts } end + defp stylesheet_for_generation(%{stylesheet_worker: worker}) when is_pid(worker) do + Volt.Tailwind.Worker.stylesheet(worker) + catch + :exit, {:noproc, _} -> {:error, :session_restarting} + :exit, {:normal, _} -> {:error, :session_restarting} + :exit, {:shutdown, _} -> {:error, :session_restarting} + end + + defp stylesheet_for_generation(_tables), do: {:error, :not_built} + + defp tailwind_key(_profile, nil), do: nil + defp tailwind_key(profile, root), do: {:profile, profile || :default, root.css || root.name} + @impl true + def call(conn, %{session_supervisor: supervisor} = config) when not is_nil(supervisor) do + case Volt.Dev.Session.Supervisor.tables(supervisor) do + %Volt.Dev.Session.Tables{} = tables -> + call_generation(conn, config, tables) + + {:error, :session_restarting} -> + conn |> Conn.send_resp(503, "Development session is restarting") |> Conn.halt() + end + end + + def call(conn, %{session: session, watcher_opts: opts} = config) + when session != :default and is_list(opts) do + with {:ok, _watcher} <- Volt.Dev.start(opts), + %Volt.Dev.Session.Tables{} = tables <- Volt.Dev.tables(session) do + call_generation(conn, config, tables) + else + {:error, _reason} -> + conn |> Conn.send_resp(503, "Development session is unavailable") |> Conn.halt() + end + end + + def call(conn, %{session: session, tables: nil} = config) when session != :default do + case Volt.Dev.tables(session) do + %Volt.Dev.Session.Tables{} = tables -> + call_generation(conn, config, tables) + + {:error, _} -> + conn |> Conn.send_resp(503, "Development session is unavailable") |> Conn.halt() + end + end + + def call(conn, %{tables: %Volt.Dev.Session.Tables{} = tables} = config) do + call_generation(conn, config, tables) + end + def call(conn, config) do Volt.Dev.ensure_watcher(config.watcher_opts) do_call(conn, config) end + defp call_generation(conn, config, tables) do + do_call(conn, %{config | tables: tables}) + rescue + error in ArgumentError -> + case __STACKTRACE__ do + [{:ets, _operation, [table | _], _} | _] + when table in [ + tables.cache, + tables.imports, + tables.globs, + tables.styles, + tables.modules, + tables.assets + ] -> + if :ets.info(table) == :undefined do + conn + |> Conn.send_resp(503, "Development session restarted; retry the request") + |> Conn.halt() + else + reraise error, __STACKTRACE__ + end + + _ -> + reraise error, __STACKTRACE__ + end + end + + defp do_call( + %Conn{request_path: path, method: method} = conn, + %{stylesheet_url: path, tables: %Volt.Dev.Session.Tables{}} = config + ) + when is_binary(path) and method in ["GET", "HEAD"] do + result = + with {:ok, css} <- stylesheet_for_generation(config.tables) do + Volt.CSS.AssetURLRewriter.rewrite_dev( + css, + config.stylesheet_source, + config.root, + config.prefix, + grant_asset: &Volt.Dev.Assets.register(&1, config.tables) + ) + end + + case result do + {:ok, css} -> + conn + |> Conn.put_resp_content_type("text/css") + |> Conn.put_resp_header("cache-control", "no-store") + |> Conn.send_resp(200, if(method == "HEAD", do: "", else: css)) + |> Conn.halt() + + {:error, _} -> + conn |> Conn.send_resp(503, "Stylesheet is not ready") |> Conn.halt() + end + end + + defp do_call(%Conn{request_path: "/@volt/assets/" <> id, method: method} = conn, config) + when method in ["GET", "HEAD"] do + case Volt.Dev.Assets.fetch(id, config.tables || config.session) do + {:ok, path} -> + if File.regular?(path) do + if method == "HEAD" do + conn + |> Conn.put_resp_content_type(Volt.Assets.mime_type(path)) + |> Conn.send_resp(200, "") + |> Conn.halt() + else + serve_public(conn, path) + end + else + conn |> Conn.send_resp(404, "Asset not found") |> Conn.halt() + end + + :error -> + conn |> Conn.send_resp(404, "Asset not registered for this session") |> Conn.halt() + end + end + defp do_call(%Conn{request_path: "/@volt/ws"} = conn, config) do conn - |> WebSockAdapter.upgrade(Volt.HMR.Socket, [], timeout: config.hmr_timeout) + |> WebSockAdapter.upgrade(Volt.HMR.Socket, [session: config.session], + timeout: config.hmr_timeout + ) |> Conn.halt() end @@ -210,7 +363,15 @@ defmodule Volt.DevServer do mod_url = virtual_url(id) code = code_for_request(result, mod_url, content_type, false) - update_module_graph(mod_url, id, id, code, source, content_type) + update_module_graph( + mod_url, + id, + id, + code, + source, + content_type, + config.tables || config.session + ) send_compiled(conn, code, result.sourcemap, content_type) @@ -267,15 +428,22 @@ defmodule Volt.DevServer do defp compilable?(path, config), do: Path.extname(path) in Volt.JS.Extensions.compilable(config.plugins) + defp file_mtime(path) do + case File.stat(path, time: :posix) do + {:ok, %{mtime: mtime}} when is_integer(mtime) -> mtime + _ -> 0 + end + end + defp serve_compiled(conn, file_path, relative, config) do module_id = module_id_for_request(file_path, conn.query_string) request_relative = relative_for_module(relative, module_id) - mtime = Volt.Format.file_mtime(file_path) + mtime = file_mtime(file_path) css_import? = css_import_request?(conn, module_id) content_type = content_type_for(module_id, css_import?) cache_key = cache_key_for(module_id, css_import?) - case Volt.Cache.get(cache_key, mtime) do + case Volt.Cache.get(cache_key, mtime, config.tables || config.session) do %{code: code, sourcemap: sourcemap} -> send_compiled(conn, code, sourcemap, content_type) @@ -308,15 +476,35 @@ defmodule Volt.DevServer do case Volt.Pipeline.compile(module_id, source, pipeline_opts(config, module_id)) do {:ok, result} -> - Volt.HMR.GlobGraph.update_from_source(file_path, source) - Volt.HMR.ImportGraph.update_from_compiled(file_path, result.code) - Volt.HMR.StyleDependencies.update_from_compile(file_path, source, result) + Volt.HMR.GlobGraph.update_from_source(file_path, source, config.tables || config.session) + + Volt.HMR.ImportGraph.update_from_compiled( + file_path, + result.code, + config.tables || config.session + ) + + Volt.HMR.StyleDependencies.update_from_compile( + file_path, + source, + result, + config.tables || config.session + ) result = rewrite_dev_css_urls(result, file_path, config) mod_url = Volt.URL.join(config.prefix, relative) code = code_for_request(result, mod_url, content_type, css_import?) graph_url = if css_import?, do: URL.append_query(mod_url, "import"), else: mod_url - update_module_graph(graph_url, graph_url, file_path, code, source, content_type) + + update_module_graph( + graph_url, + graph_url, + file_path, + code, + source, + content_type, + config.tables || config.session + ) entry = %Volt.DevServer.CacheEntry{ code: code, @@ -326,7 +514,7 @@ defmodule Volt.DevServer do content_type: content_type } - Volt.Cache.put(cache_key, mtime, entry) + Volt.Cache.put(cache_key, mtime, entry, config.tables || config.session) send_compiled(conn, code, result.sourcemap, content_type) {:error, errors} -> @@ -353,7 +541,7 @@ defmodule Volt.DevServer do ] end - defp update_module_graph(mod_url, cache_key, file_path, code, source, content_type) do + defp update_module_graph(mod_url, cache_key, file_path, code, source, content_type, session) do imports = case OXC.select(code, Path.basename(file_path), :import_specifiers) do {:ok, imports} -> Enum.map(imports, &normalize_module_graph_import(&1, mod_url)) @@ -361,6 +549,7 @@ defmodule Volt.DevServer do end Volt.HMR.ModuleGraph.update_module(mod_url, cache_key, file_path, imports, + session: session, type: module_graph_type(content_type), self_accepting: Volt.HMR.Boundary.self_accepting?(source) ) @@ -518,14 +707,18 @@ defmodule Volt.DevServer do defp cache_key_for(file_path, false), do: file_path defp rewrite_dev_css_urls(%{type: :css, code: code} = result, file_path, config) do - case Volt.CSS.AssetURLRewriter.rewrite_dev(code, file_path, config.root, config.prefix) do + case Volt.CSS.AssetURLRewriter.rewrite_dev(code, file_path, config.root, config.prefix, + grant_asset: &Volt.Dev.Assets.register(&1, config.tables || config.session) + ) do {:ok, code} -> %{result | code: code} {:error, _} -> result end end defp rewrite_dev_css_urls(%{css: css} = result, file_path, config) when is_binary(css) do - case Volt.CSS.AssetURLRewriter.rewrite_dev(css, file_path, config.root, config.prefix) do + case Volt.CSS.AssetURLRewriter.rewrite_dev(css, file_path, config.root, config.prefix, + grant_asset: &Volt.Dev.Assets.register(&1, config.tables || config.session) + ) do {:ok, css} -> %{result | css: css} {:error, _} -> result end @@ -590,6 +783,9 @@ defmodule Volt.DevServer do String.starts_with?(specifier, "#") -> rewrite_package_import(specifier, importer, config) + Path.type(elem(URL.split_query(specifier), 0)) == :absolute -> + rewrite_resolved_path(specifier, config) + NPM.Resolution.PackageResolver.relative?(specifier) -> rewrite_relative(specifier, importer, config) diff --git a/lib/volt/dev_server/config.ex b/lib/volt/dev_server/config.ex index 7dc82e4..3b320ff 100644 --- a/lib/volt/dev_server/config.ex +++ b/lib/volt/dev_server/config.ex @@ -15,5 +15,10 @@ defmodule Volt.DevServer.Config do module_types: %{}, define: %{}, hmr_timeout: 60_000, + session: :default, + tables: nil, + stylesheet_url: nil, + stylesheet_source: nil, + session_supervisor: nil, watcher_opts: nil end diff --git a/lib/volt/ets.ex b/lib/volt/ets.ex index 5ee8173..6ac6ffc 100644 --- a/lib/volt/ets.ex +++ b/lib/volt/ets.ex @@ -1,6 +1,12 @@ defmodule Volt.ETS do @moduledoc "Small helpers for Volt's named ETS tables." + @doc "Resolve an owned session table or the legacy table at the API boundary." + def session_table(%Volt.Dev.Session.Tables{} = tables, kind, _legacy), + do: Map.fetch!(tables, kind) + + def session_table(_session, _kind, legacy), do: legacy + @doc "Create a public named set table optimized for concurrent reads." def create_named_set(table) do :ets.new(table, [:named_table, :set, :public, read_concurrency: true]) @@ -19,6 +25,12 @@ defmodule Volt.ETS do :ok end + @doc "Delete rows keyed by {session, key} for one exact session." + def clear_session(table, session) do + :ets.select_delete(table, [{{{:"$1", :_}, :_}, [{:"=:=", :"$1", {:const, session}}], [true]}]) + :ok + end + @doc "Remove all rows from an ETS table and return `:ok`." def clear(table) do :ets.delete_all_objects(table) diff --git a/lib/volt/format.ex b/lib/volt/format.ex index e135787..dd90c84 100644 --- a/lib/volt/format.ex +++ b/lib/volt/format.ex @@ -4,17 +4,6 @@ defmodule Volt.Format do def format_size(bytes) when bytes < 1024, do: "#{bytes} B" def format_size(bytes), do: "#{Float.round(bytes / 1024, 1)} KB" - def file_mtime(path) do - case File.stat(path, time: :posix) do - {:ok, %{mtime: mtime}} when is_integer(mtime) -> mtime - _ -> 0 - end - end - - def content_hash(content) do - :crypto.hash(:sha256, content) |> Base.encode16(case: :lower) |> binary_part(0, 8) - end - def gzip_size(content) when is_binary(content) do :zlib.gzip(content) |> byte_size() end diff --git a/lib/volt/hmr.ex b/lib/volt/hmr.ex index 5c4d356..5f603d6 100644 --- a/lib/volt/hmr.ex +++ b/lib/volt/hmr.ex @@ -10,8 +10,10 @@ defmodule Volt.HMR do @doc "Broadcast an HMR message to all connected clients." @spec broadcast(:update | :error | :ping | :pong, term()) :: :ok - def broadcast(type, payload \\ nil) do - Registry.dispatch(Volt.HMR.Registry, :clients, fn entries -> + def broadcast(type, payload \\ nil, opts \\ []) do + session = Keyword.get(opts, :session, :default) + + Registry.dispatch(Volt.HMR.Registry, Volt.HMR.Channel.key(session), fn entries -> for {pid, _} <- entries do send(pid, {:volt_hmr, type, payload}) end @@ -31,26 +33,27 @@ defmodule Volt.HMR do payload = maybe_put(payload, :boundary, Keyword.get(opts, :boundary)) payload = maybe_put(payload, :timestamp, Keyword.get(opts, :timestamp)) - broadcast(:update, payload) + broadcast(:update, payload, opts) end @doc "Broadcast a full page reload request for a changed path." @spec full_reload(String.t()) :: :ok - def full_reload(path), do: update(path, [:full]) + def full_reload(path, opts \\ []), do: update(path, [:full], opts) @doc "Broadcast a style-only update for a changed stylesheet path." @spec style_update(String.t()) :: :ok - def style_update(path), do: update(path, [:style]) + 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), do: broadcast(:error, %{path: path, reason: reason}) + def error(path, reason, opts \\ []), do: broadcast(:error, %{path: path, reason: reason}, opts) @doc "Invalidate Volt's dev compilation state for a source file without broadcasting." @spec invalidate_file(String.t()) :: :ok - def invalidate_file(path) do - Volt.Cache.evict_file(path) - Volt.HMR.ModuleGraph.invalidate_file(path) + def invalidate_file(path, opts \\ []) do + session = Keyword.get(opts, :session, :default) + Volt.Cache.evict_file(path, session) + Volt.HMR.ModuleGraph.invalidate_file(path, System.system_time(:millisecond), session) :ok end diff --git a/lib/volt/hmr/boundary.ex b/lib/volt/hmr/boundary.ex index 40b09a9..410b865 100644 --- a/lib/volt/hmr/boundary.ex +++ b/lib/volt/hmr/boundary.ex @@ -33,18 +33,18 @@ defmodule Volt.HMR.Boundary do """ @spec find_boundary(String.t(), (String.t() -> String.t() | nil)) :: {:ok, String.t()} | :full_reload - def find_boundary(changed_path, read_source) do + def find_boundary(changed_path, read_source, session \\ :default) do source = read_source.(changed_path) cond do source && self_accepting?(source) -> {:ok, changed_path} - graph_boundary = find_graph_boundary(changed_path, read_source) -> + graph_boundary = find_graph_boundary(changed_path, read_source, session) -> graph_boundary true -> - walk_up(changed_path, changed_path, read_source, MapSet.new([changed_path])) + walk_up(changed_path, changed_path, read_source, MapSet.new([changed_path]), session) end end @@ -127,9 +127,9 @@ defmodule Volt.HMR.Boundary do resolved == changed_path or Path.rootname(resolved) == Path.rootname(changed_path) end - defp find_graph_boundary(changed_path, read_source) do + defp find_graph_boundary(changed_path, read_source, session) do changed_path - |> Volt.HMR.ModuleGraph.get_by_file() + |> Volt.HMR.ModuleGraph.get_by_file(session) |> case do [] -> nil @@ -139,12 +139,13 @@ defmodule Volt.HMR.Boundary do nodes, changed_path, read_source, - MapSet.new(Enum.map(nodes, & &1.id)) + MapSet.new(Enum.map(nodes, & &1.id)), + session ) end end - defp find_graph_boundary_in_nodes(nodes, changed_path, read_source, visited) do + defp find_graph_boundary_in_nodes(nodes, changed_path, read_source, visited, session) do Enum.find_value(nodes, :full_reload, fn node -> cond do graph_accepts_update?(node, changed_path, read_source) -> @@ -155,12 +156,13 @@ defmodule Volt.HMR.Boundary do true -> node.importers - |> Enum.flat_map(&List.wrap(Volt.HMR.ModuleGraph.get_by_id(&1))) + |> Enum.flat_map(&List.wrap(Volt.HMR.ModuleGraph.get_by_id(&1, session))) |> Enum.reject(&MapSet.member?(visited, &1.id)) |> find_graph_boundary_in_nodes( changed_path, read_source, - MapSet.union(visited, node.importers) + MapSet.union(visited, node.importers), + session ) |> case do :full_reload -> nil @@ -180,47 +182,50 @@ defmodule Volt.HMR.Boundary do end end - defp walk_up(path, changed_path, read_source, visited) do - case find_importers(path) do + defp walk_up(path, changed_path, read_source, visited, session) do + case find_importers(path, session) do [] -> :full_reload - parents -> find_boundary_in_parents(parents, changed_path, read_source, visited) + parents -> find_boundary_in_parents(parents, changed_path, read_source, visited, session) end end - defp find_boundary_in_parents(parents, changed_path, read_source, visited) do + defp find_boundary_in_parents(parents, changed_path, read_source, visited, session) do Enum.find_value(parents, :full_reload, fn parent -> if MapSet.member?(visited, parent) do nil else - visit_parent(parent, changed_path, read_source, MapSet.put(visited, parent)) + visit_parent(parent, changed_path, read_source, MapSet.put(visited, parent), session) end end) end - defp visit_parent(parent, changed_path, read_source, visited) do + defp visit_parent(parent, changed_path, read_source, visited, session) do source = read_source.(parent) if source != nil and (self_accepting?(source) or dependency_accepting?(source, parent, changed_path)) do {:ok, parent} else - case walk_up(parent, changed_path, read_source, visited) do + case walk_up(parent, changed_path, read_source, visited, session) do {:ok, _} = found -> found :full_reload -> nil end end end - defp find_importers(path) do + defp find_importers(path, session) do basename = Path.basename(path) rootname = Path.rootname(basename) - Volt.HMR.ImportGraph.dependents_matching(fn specifier -> - spec_base = specifier |> String.split("/") |> List.last() + Volt.HMR.ImportGraph.dependents_matching( + fn specifier -> + spec_base = specifier |> String.split("/") |> List.last() - spec_base == basename or - spec_base == rootname or - Path.rootname(spec_base) == rootname - end) + spec_base == basename or + spec_base == rootname or + Path.rootname(spec_base) == rootname + end, + session + ) end end diff --git a/lib/volt/hmr/channel.ex b/lib/volt/hmr/channel.ex new file mode 100644 index 0000000..63efceb --- /dev/null +++ b/lib/volt/hmr/channel.ex @@ -0,0 +1,6 @@ +defmodule Volt.HMR.Channel do + @moduledoc "Registry channel identity for development-session subscribers." + + def key(:default), do: :clients + def key(session), do: {:clients, session} +end diff --git a/lib/volt/hmr/glob_graph.ex b/lib/volt/hmr/glob_graph.ex index d7a5a96..baa6675 100644 --- a/lib/volt/hmr/glob_graph.ex +++ b/lib/volt/hmr/glob_graph.ex @@ -3,34 +3,44 @@ defmodule Volt.HMR.GlobGraph do @table :volt_hmr_glob_graph + defp table(session), do: Volt.ETS.session_table(session, :globs, @table) + @doc "Create the glob graph ETS table." def create_table, do: Volt.ETS.create_named_set(@table) @doc "Store glob patterns owned by an importer." - def update(importer, globs), do: Volt.ETS.put(@table, {importer, globs}) + def update(importer, globs, session \\ :default), + do: Volt.ETS.put(table(session), {{session, importer}, globs}) @doc "Extract and store `import.meta.glob()` patterns from source." - def update_from_source(path, source) do + def update_from_source(path, source, session \\ :default) do globs = source |> Volt.JS.Transforms.GlobImports.patterns(Path.basename(path)) |> Enum.map(&expand_glob_pattern(&1, Path.dirname(path))) - update(path, globs) + update(path, globs, session) end @doc "Find all files with an `import.meta.glob()` pattern matching `path`." - def dependents(path) do + def dependents(path, session \\ :default) do :ets.foldl( - fn {importer, globs}, acc -> - if glob_match?(globs, path), do: [importer | acc], else: acc + fn + {{^session, importer}, globs}, acc -> + if glob_match?(globs, path), do: [importer | acc], else: acc + + _, acc -> + acc end, [], - @table + table(session) ) end - def remove(path), do: Volt.ETS.delete(@table, path) + def remove(path, session \\ :default), do: Volt.ETS.delete(table(session), {session, path}) + + @doc "Remove glob ownership for one session." + def clear_session(session), do: Volt.ETS.clear_session(table(session), session) def clear, do: Volt.ETS.clear(@table) diff --git a/lib/volt/hmr/import_graph.ex b/lib/volt/hmr/import_graph.ex index 08a7432..7a15590 100644 --- a/lib/volt/hmr/import_graph.ex +++ b/lib/volt/hmr/import_graph.ex @@ -9,30 +9,34 @@ defmodule Volt.HMR.ImportGraph do @table :volt_hmr_import_graph + defp table(session), do: Volt.ETS.session_table(session, :imports, @table) + @doc "Create the import graph ETS table. Called once from Application.start/2." @spec create_table :: :ok def create_table, do: Volt.ETS.create_named_set(@table) @doc "Update the imports for a file path." @spec update(String.t(), [String.t()]) :: :ok - def update(path, imports), do: Volt.ETS.put(@table, {path, imports}) + def update(path, imports, session \\ :default) do + Volt.ETS.put(table(session), {{session, path}, Enum.uniq(imports)}) + end @doc "Update imports from compiled code." @spec update_from_compiled(String.t(), String.t()) :: :ok - def update_from_compiled(path, compiled_code) do + def update_from_compiled(path, compiled_code, session \\ :default) do imports = case OXC.select(compiled_code, Path.basename(path), :import_specifiers) do {:ok, imports} -> imports _ -> [] end - update(path, imports) + update(path, imports, session) end @doc "Get the imports for a file path." @spec imports_of(String.t()) :: [String.t()] - def imports_of(path) do - case :ets.lookup(@table, path) do + def imports_of(path, session \\ :default) do + case :ets.lookup(table(session), {session, path}) do [{_, imports}] -> imports [] -> [] end @@ -44,14 +48,8 @@ defmodule Volt.HMR.ImportGraph do Used by fallback HMR boundary lookup to propagate changes upward through raw imports. """ @spec dependents(String.t()) :: [String.t()] - def dependents(specifier) do - :ets.foldl( - fn {path, imports}, acc -> - if specifier in imports, do: [path | acc], else: acc - end, - [], - @table - ) + def dependents(specifier, session \\ :default) do + dependents_matching(&(&1 == specifier), session) end @doc """ @@ -61,21 +59,28 @@ defmodule Volt.HMR.ImportGraph do if it matches the file being searched for. """ @spec dependents_matching((String.t() -> boolean())) :: [String.t()] - def dependents_matching(predicate) do + def dependents_matching(predicate, session \\ :default) do :ets.foldl( - fn {path, imports}, acc -> - if Enum.any?(imports, predicate), do: [path | acc], else: acc + fn + {{^session, path}, imports}, acc -> + if Enum.any?(imports, predicate), do: [path | acc], else: acc + + _, acc -> + acc end, [], - @table + table(session) ) end @doc "Remove a file from the graph." @spec remove(String.t()) :: :ok - def remove(path), do: Volt.ETS.delete(@table, path) + def remove(path, session \\ :default), do: Volt.ETS.delete(table(session), {session, path}) @doc "Clear the entire graph." @spec clear :: :ok def clear, do: Volt.ETS.clear(@table) + + @doc "Clear raw import edges for one session." + def clear_session(session), do: Volt.ETS.clear_session(table(session), session) end diff --git a/lib/volt/hmr/module_graph.ex b/lib/volt/hmr/module_graph.ex index fed725d..fbadad8 100644 --- a/lib/volt/hmr/module_graph.ex +++ b/lib/volt/hmr/module_graph.ex @@ -1,18 +1,12 @@ defmodule Volt.HMR.ModuleGraph do - @moduledoc """ - ETS-backed dev-server module graph. + @moduledoc "Session-scoped module graph indexed by URL, resolved identity, and source file." - The graph is keyed by served URL, resolved module id, and source file. It is - the primary source for HMR boundary lookup: served modules record their - resolved imports, importers, query variants, and whether the module accepts - itself with `import.meta.hot.accept()`. - """ + alias Volt.ETS @table :volt_hmr_module_graph defmodule Node do - @moduledoc "A dev-server module graph node." - + @moduledoc "A served browser module." defstruct url: nil, id: nil, file: nil, @@ -23,134 +17,120 @@ defmodule Volt.HMR.ModuleGraph do last_invalidated_at: nil end - @doc "Create the module graph ETS table." - def create_table do - :ets.new(@table, [:named_table, :set, :public, read_concurrency: true]) - :ok - end + defp table(session), do: Volt.ETS.session_table(session, :modules, @table) + + def create_table, do: ETS.create_named_set(@table) - @doc "Upsert a module and update importer links for its resolved imports." def update_module(url, id, file, imports, opts \\ []) do - old_node = get_by_id(id) - remove_old_importer_links(old_node, id) + session = Keyword.get(opts, :session, :default) + old = get_by_id(id, session) + unlink_imports(old, session) + if old && old.url != url, do: ETS.delete(table(session), {session, {:url, old.url}}) + if old && old.file != file, do: unlink_file(old, session) + + importers = + for node <- nodes(session), + MapSet.member?(node.imports, id), + into: MapSet.new(), + do: node.id node = %Node{ url: url, id: id, file: file, - type: Keyword.get(opts, :type, module_type(url)), imports: MapSet.new(imports), - importers: existing_importers(id, old_node), + importers: importers, + type: Keyword.get(opts, :type, module_type(url)), self_accepting: Keyword.get(opts, :self_accepting, false) } - put_node(node) - Enum.each(node.imports, &put_importer_link(&1, id)) + put_node(node, session) + + Enum.each(node.imports, fn imported_id -> + if imported = get_by_id(imported_id, session) do + put_node(%{imported | importers: MapSet.put(imported.importers, id)}, session) + end + end) + :ok end - def get_by_url(url), do: lookup({:url, url}) - def get_by_id(id), do: lookup({:id, id}) - - def get_by_file(file) do - case :ets.lookup(@table, {:file, file}) do - [{_, ids}] -> Enum.flat_map(ids, &List.wrap(get_by_id(&1))) - [] -> [] + def get_by_url(url, session \\ :default) do + case lookup({:url, url}, session) do + nil -> nil + id -> get_by_id(id, session) end end - @doc "Mark every graph node for a file as invalidated and return affected nodes." - def invalidate_file(file, timestamp \\ System.system_time(:millisecond)) do - nodes = get_by_file(file) + def get_by_id(id, session \\ :default), do: lookup({:id, id}, session) - Enum.each(nodes, fn node -> - put_node(%{node | last_invalidated_at: timestamp}) - end) + def get_by_file(file, session \\ :default) do + (lookup({:file, file}, session) || MapSet.new()) + |> Enum.flat_map(&List.wrap(get_by_id(&1, session))) + end + def invalidate_file(file, timestamp \\ System.system_time(:millisecond), session \\ :default) do + nodes = get_by_file(file, session) + Enum.each(nodes, &put_node(%{&1 | last_invalidated_at: timestamp}, session)) nodes end - @doc "Remove all nodes for a file and unlink them from importers/imports." - def remove_file(file) do - file - |> get_by_file() - |> Enum.each(&remove_node/1) + def remove_file(file, session \\ :default) do + Enum.each(get_by_file(file, session), fn node -> + unlink_imports(node, session) - :ets.delete(@table, {:file, file}) - :ok - end + Enum.each(node.importers, fn id -> + if importer = get_by_id(id, session) do + put_node(%{importer | imports: MapSet.delete(importer.imports, node.id)}, session) + end + end) - def clear do - :ets.delete_all_objects(@table) - :ok - end + ETS.delete(table(session), {session, {:url, node.url}}) + ETS.delete(table(session), {session, {:id, node.id}}) + end) - defp put_node(node) do - :ets.insert(@table, {{:url, node.url}, node.id}) - :ets.insert(@table, {{:id, node.id}, node}) - :ets.insert(@table, {{:file, node.file}, file_ids(node.file, node.id)}) + ETS.delete(table(session), {session, {:file, file}}) end - defp existing_importers(id, nil), do: existing_importers(id, %Node{}) - - defp existing_importers(id, old_node) do - @table - |> :ets.tab2list() - |> Enum.reduce(old_node.importers, fn - {{:id, importer_id}, %Node{imports: imports}}, acc when importer_id != id -> - if MapSet.member?(imports, id), do: MapSet.put(acc, importer_id), else: acc - - _entry, acc -> - acc - end) + def clear, do: ETS.clear(@table) + def clear_session(session), do: ETS.clear_session(table(session), session) + + defp nodes(session) do + :ets.foldl( + fn + {{^session, {:id, _}}, node}, acc -> [node | acc] + _, acc -> acc + end, + [], + table(session) + ) end - defp put_importer_link(import_id, importer_id) do - case get_by_id(import_id) do - nil -> :ok - node -> put_node(%{node | importers: MapSet.put(node.importers, importer_id)}) - end + defp put_node(node, session) do + ids = lookup({:file, node.file}, session) || MapSet.new() + ETS.put(table(session), {{session, {:url, node.url}}, node.id}) + ETS.put(table(session), {{session, {:id, node.id}}, node}) + ETS.put(table(session), {{session, {:file, node.file}}, MapSet.put(ids, node.id)}) end - defp remove_old_importer_links(nil, _id), do: :ok - - defp remove_old_importer_links(node, id) do - Enum.each(node.imports, fn import_id -> - case get_by_id(import_id) do - nil -> :ok - imported -> put_node(%{imported | importers: MapSet.delete(imported.importers, id)}) - end - end) + defp unlink_file(node, session) do + ids = lookup({:file, node.file}, session) || MapSet.new() + ETS.put(table(session), {{session, {:file, node.file}}, MapSet.delete(ids, node.id)}) end - defp remove_node(node) do - remove_old_importer_links(node, node.id) + defp unlink_imports(nil, _session), do: :ok - Enum.each(node.importers, fn importer_id -> - case get_by_id(importer_id) do - nil -> :ok - importer -> put_node(%{importer | imports: MapSet.delete(importer.imports, node.id)}) + defp unlink_imports(node, session) do + Enum.each(node.imports, fn id -> + if imported = get_by_id(id, session) do + put_node(%{imported | importers: MapSet.delete(imported.importers, node.id)}, session) end end) - - :ets.delete(@table, {:url, node.url}) - :ets.delete(@table, {:id, node.id}) - end - - defp file_ids(file, id) do - existing = - case :ets.lookup(@table, {:file, file}) do - [{_, ids}] -> ids - [] -> MapSet.new() - end - - MapSet.put(existing, id) end - defp lookup(key) do - case :ets.lookup(@table, key) do - [{_, %Node{} = node}] -> node - [{_, id}] when elem(key, 0) == :url -> get_by_id(id) + defp lookup(key, session) do + case :ets.lookup(table(session), {session, key}) do + [{_, value}] -> value [] -> nil end end diff --git a/lib/volt/hmr/socket.ex b/lib/volt/hmr/socket.ex index 34433c4..65a9c95 100644 --- a/lib/volt/hmr/socket.ex +++ b/lib/volt/hmr/socket.ex @@ -9,8 +9,11 @@ defmodule Volt.HMR.Socket do require Logger @impl true - def init(_args) do - Registry.register(Volt.HMR.Registry, :clients, nil) + def init(nil), do: init([]) + + def init(args) do + session = Keyword.get(args, :session, :default) + Registry.register(Volt.HMR.Registry, Volt.HMR.Channel.key(session), nil) {:ok, %{}} end diff --git a/lib/volt/hmr/style_dependencies.ex b/lib/volt/hmr/style_dependencies.ex index f5d4c5e..e739c67 100644 --- a/lib/volt/hmr/style_dependencies.ex +++ b/lib/volt/hmr/style_dependencies.ex @@ -11,14 +11,14 @@ defmodule Volt.HMR.StyleDependencies do @doc "Update stylesheet dependency state for a compiled source file." @spec update_from_compile(String.t(), String.t(), Volt.Pipeline.Result.t() | map()) :: :ok - def update_from_compile(path, source, result) do + def update_from_compile(path, source, result, session \\ :default) do case dependency_source(path, source, result) do nil -> - Volt.HMR.StyleGraph.remove(path) + Volt.HMR.StyleGraph.remove(path, session) css -> dependencies = Volt.CSS.Dependencies.resolve(css, path) - Volt.HMR.StyleGraph.update(path, dependencies) + Volt.HMR.StyleGraph.update(path, dependencies, session) end end diff --git a/lib/volt/hmr/style_graph.ex b/lib/volt/hmr/style_graph.ex index 91f4dc4..713189c 100644 --- a/lib/volt/hmr/style_graph.ex +++ b/lib/volt/hmr/style_graph.ex @@ -10,26 +10,28 @@ defmodule Volt.HMR.StyleGraph do @table :volt_hmr_style_graph + defp table(session), do: Volt.ETS.session_table(session, :styles, @table) + @doc "Create the stylesheet graph ETS table. Called once from Application.start/2." @spec create_table :: :ok def create_table, do: Volt.ETS.create_named_set(@table) @doc "Update resolved dependencies for a stylesheet source file." @spec update(String.t(), [String.t()]) :: :ok - def update(path, dependencies) do - old_dependencies = dependencies_of(path) + def update(path, dependencies, session \\ :default) do + old_dependencies = dependencies_of(path, session) dependencies = dependencies |> Enum.uniq() |> MapSet.new() Enum.each(old_dependencies, fn dependency -> - dependents = dependency |> dependents_of() |> MapSet.new() |> MapSet.delete(path) - put_dependents(dependency, dependents) + dependents = dependency |> dependents_of(session) |> MapSet.new() |> MapSet.delete(path) + put_dependents(dependency, dependents, session) end) - Volt.ETS.put(@table, {{:dependencies, path}, dependencies}) + Volt.ETS.put(table(session), {{session, {:dependencies, path}}, dependencies}) Enum.each(dependencies, fn dependency -> - dependents = dependency |> dependents_of() |> MapSet.new() |> MapSet.put(path) - put_dependents(dependency, dependents) + dependents = dependency |> dependents_of(session) |> MapSet.new() |> MapSet.put(path) + put_dependents(dependency, dependents, session) end) :ok @@ -37,18 +39,20 @@ defmodule Volt.HMR.StyleGraph do @doc "Return direct resolved dependencies for a stylesheet source file." @spec dependencies_of(String.t()) :: [String.t()] - def dependencies_of(path), do: lookup_set({:dependencies, path}) + def dependencies_of(path, session \\ :default), + do: lookup_set({session, {:dependencies, path}}, session) @doc "Return all transitive stylesheets that depend on a source file." @spec dependents(String.t()) :: [String.t()] - def dependents(path), do: dependents(path, MapSet.new([path])) + def dependents(path, session \\ :default), + do: walk_dependents(path, MapSet.new([path]), session) @doc "Remove a stylesheet from the graph." @spec remove(String.t()) :: :ok - def remove(path) do - update(path, []) - Volt.ETS.delete(@table, {:dependencies, path}) - Volt.ETS.delete(@table, {:dependents, path}) + def remove(path, session \\ :default) do + update(path, [], session) + Volt.ETS.delete(table(session), {session, {:dependencies, path}}) + Volt.ETS.delete(table(session), {session, {:dependents, path}}) :ok end @@ -56,28 +60,31 @@ defmodule Volt.HMR.StyleGraph do @spec clear :: :ok def clear, do: Volt.ETS.clear(@table) - defp dependents(path, seen) do + @doc "Clear stylesheet edges belonging to one session." + def clear_session(session), do: Volt.ETS.clear_session(table(session), session) + + defp walk_dependents(path, seen, session) do path - |> dependents_of() + |> dependents_of(session) |> Enum.reject(&MapSet.member?(seen, &1)) |> Enum.flat_map(fn stylesheet -> - [stylesheet | dependents(stylesheet, MapSet.put(seen, stylesheet))] + [stylesheet | walk_dependents(stylesheet, MapSet.put(seen, stylesheet), session)] end) |> Enum.uniq() end - defp dependents_of(path), do: lookup_set({:dependents, path}) + defp dependents_of(path, session), do: lookup_set({session, {:dependents, path}}, session) - defp put_dependents(path, dependents) do + defp put_dependents(path, dependents, session) do if MapSet.size(dependents) == 0 do - Volt.ETS.delete(@table, {:dependents, path}) + Volt.ETS.delete(table(session), {session, {:dependents, path}}) else - Volt.ETS.put(@table, {{:dependents, path}, dependents}) + Volt.ETS.put(table(session), {{session, {:dependents, path}}, dependents}) end end - defp lookup_set(key) do - case :ets.lookup(@table, key) do + defp lookup_set(key, session) do + case :ets.lookup(table(session), key) do [{_, set}] -> MapSet.to_list(set) [] -> [] end diff --git a/lib/volt/js/helpers.ex b/lib/volt/js/discovery.ex similarity index 72% rename from lib/volt/js/helpers.ex rename to lib/volt/js/discovery.ex index a092c94..9a4305d 100644 --- a/lib/volt/js/helpers.ex +++ b/lib/volt/js/discovery.ex @@ -1,7 +1,7 @@ -defmodule Volt.JS.Helpers do +defmodule Volt.JS.Discovery do @moduledoc "Shared JavaScript file discovery helpers for Mix tasks." - def discover_files(opts \\ []) do + def files(opts \\ []) do config = Volt.Config.build() tool_config = discovery_config(Keyword.get(opts, :tool)) root = Keyword.get(tool_config, :root, config.root) @@ -33,10 +33,16 @@ defmodule Volt.JS.Helpers do |> Enum.sort() end - def discover_format_files do - discover_files(tool: :format, only: Volt.JS.Extensions.formattable()) + def format_files do + files(tool: :format, only: Volt.JS.Extensions.formattable()) end defp discovery_config(nil), do: [] - defp discovery_config(tool), do: Application.get_env(:volt, tool, []) + + defp discovery_config(tool) do + config = Application.get_env(:volt, tool, []) + + # The bundler reads :format as an atom, so it holds no discovery keys. + if Keyword.keyword?(config), do: config, else: [] + end end diff --git a/lib/volt/js/format.ex b/lib/volt/js/format.ex index 9743f34..0df0bb9 100644 --- a/lib/volt/js/format.ex +++ b/lib/volt/js/format.ex @@ -27,9 +27,14 @@ defmodule Volt.JS.Format do @discovery_keys ~w(root sources ignore)a def load_config do - case Application.get_env(:volt, :format) do - nil -> load_json_config() - opts when is_list(opts) -> Keyword.drop(opts, @discovery_keys) + opts = Application.get_env(:volt, :format) + + # The bundler reads the same key, where the value is an atom such as :esm. + # Only a keyword list carries formatter options. + if Keyword.keyword?(opts) do + Keyword.drop(opts, @discovery_keys) + else + load_json_config() end end diff --git a/lib/volt/js/imports/extractor.ex b/lib/volt/js/import_extractor.ex similarity index 100% rename from lib/volt/js/imports/extractor.ex rename to lib/volt/js/import_extractor.ex diff --git a/lib/volt/js/imports/result.ex b/lib/volt/js/import_extractor/result.ex similarity index 100% rename from lib/volt/js/imports/result.ex rename to lib/volt/js/import_extractor/result.ex diff --git a/lib/volt/js/prebundle/entry.ex b/lib/volt/js/prebundle_entry.ex similarity index 100% rename from lib/volt/js/prebundle/entry.ex rename to lib/volt/js/prebundle_entry.ex diff --git a/lib/volt/js/prebundle/export.ex b/lib/volt/js/prebundle_entry/export.ex similarity index 100% rename from lib/volt/js/prebundle/export.ex rename to lib/volt/js/prebundle_entry/export.ex diff --git a/lib/volt/js/prebundle/import.ex b/lib/volt/js/prebundle_entry/import.ex similarity index 100% rename from lib/volt/js/prebundle/import.ex rename to lib/volt/js/prebundle_entry/import.ex diff --git a/lib/volt/js/vendor.ex b/lib/volt/js/vendor.ex index fa2c946..09de73e 100644 --- a/lib/volt/js/vendor.ex +++ b/lib/volt/js/vendor.ex @@ -22,6 +22,12 @@ defmodule Volt.JS.Vendor do Path.join(build_path, "volt/vendor") end + defp context_dir(module_dirs, plugins, module_types) do + signature = browser_signature(module_dirs, plugins, module_types) + hash = :crypto.hash(:sha256, :erlang.term_to_binary(signature)) |> Base.encode16(case: :lower) + Path.join(cache_dir(), hash) + end + @doc """ Scan source files and pre-bundle any bare npm imports. @@ -45,7 +51,7 @@ defmodule Volt.JS.Vendor do module_types = Keyword.get(opts, :module_types, %{}) with {:ok, specifiers} <- scan_bare_imports(root, plugins), - :ok <- ensure_cache_dir() do + :ok <- ensure_cache_dir(module_dirs, plugins, module_types) do specifiers = specifiers |> Enum.map(&Volt.PluginRunner.prebundle_alias(plugins, &1)) @@ -65,9 +71,9 @@ defmodule Volt.JS.Vendor do @spec bundle_on_demand(String.t(), String.t() | nil, keyword()) :: {:ok, String.t()} | {:error, term()} def bundle_on_demand(specifier, node_modules, opts \\ []) do - ensure_cache_dir() {plugins, resolve_dirs, module_types} = normalize_on_demand_opts(opts) module_dirs = module_dirs(node_modules, resolve_dirs) + ensure_cache_dir(module_dirs, plugins, module_types) specifier = Volt.PluginRunner.prebundle_alias(plugins, specifier) case bundle_vendor(specifier, module_dirs, false, plugins, module_types) do @@ -114,20 +120,19 @@ defmodule Volt.JS.Vendor do Read a pre-bundled vendor file by specifier. """ @spec read(String.t()) :: {:ok, String.t()} | {:error, :not_found} - def read(specifier), do: read_cached(specifier) + def read(specifier), do: read(specifier, []) @doc "Read a pre-bundled vendor file when its cache signature matches the current options." @spec read(String.t(), keyword()) :: {:ok, String.t()} | {:error, :not_found} - def read("chunks/" <> _ = specifier, _opts), do: read_cached(specifier) - def read(specifier, opts) do {plugins, resolve_dirs, module_types} = normalize_on_demand_opts(opts) node_modules = Keyword.get(opts, :node_modules) module_dirs = module_dirs(node_modules, resolve_dirs) specifier = Volt.PluginRunner.prebundle_alias(plugins, specifier) - if cache_fresh?(specifier, module_dirs, plugins, module_types) do - read_cached(specifier) + if String.starts_with?(specifier, "chunks/") or + cache_fresh?(specifier, module_dirs, plugins, module_types) do + read_cached(specifier, context_dir(module_dirs, plugins, module_types)) else {:error, :not_found} end @@ -186,7 +191,7 @@ defmodule Volt.JS.Vendor do defp prebundle_vendors([], _module_dirs, _force, _plugins, _module_types), do: {:ok, %{}} defp prebundle_vendors(specifiers, module_dirs, force, plugins, module_types) do - vendor_map = Map.new(specifiers, &{&1, cache_path(&1)}) + vendor_map = Map.new(specifiers, &{&1, cache_path(&1, module_dirs, plugins, module_types)}) if not force and Enum.all?(specifiers, &cache_fresh?(&1, module_dirs, plugins, module_types)) do {:ok, vendor_map} @@ -232,7 +237,7 @@ defmodule Volt.JS.Vendor do Bundle.new() |> Bundle.entries(bundle_entries) |> Bundle.cwd(project_root(module_dirs)) - |> Bundle.outdir(cache_dir()) + |> Bundle.outdir(context_dir(module_dirs, plugins, module_types)) |> Bundle.format(:esm) |> Bundle.resolve( conditions: Volt.JS.Resolution.browser_conditions(), @@ -262,9 +267,9 @@ defmodule Volt.JS.Vendor do defp write_vendor_cache_metadata(specifiers, module_dirs, plugins, module_types) do Enum.each(specifiers, fn specifier -> - if File.regular?(cache_path(specifier)) do + if File.regular?(cache_path(specifier, module_dirs, plugins, module_types)) do File.write!( - cache_meta_path(specifier), + cache_meta_path(specifier, module_dirs, plugins, module_types), cache_signature(specifier, module_dirs, plugins, module_types) ) end @@ -307,7 +312,7 @@ defmodule Volt.JS.Vendor do end defp bundle_vendor(specifier, module_dirs, force, plugins, module_types) do - path = cache_path(specifier) + path = cache_path(specifier, module_dirs, plugins, module_types) if not force and File.regular?(path) and cache_fresh?(specifier, module_dirs, plugins, module_types) do @@ -318,7 +323,7 @@ defmodule Volt.JS.Vendor do end defp do_bundle_vendor(specifier, module_dirs, output_path, plugins, module_types) do - case prebundle_entry(specifier, module_dirs, plugins) do + case prebundle_entry(specifier, module_dirs, plugins, module_types) do {:ok, entry_path, project_root} -> bundle_opts = [ @@ -353,17 +358,22 @@ defmodule Volt.JS.Vendor do end end - defp prebundle_entry(specifier, module_dirs, plugins) do + defp prebundle_entry(specifier, module_dirs, plugins, module_types) do case Volt.PluginRunner.prebundle_entry(plugins, specifier) do {:source, filename, source} -> - synthetic_prebundle_entry(specifier, filename, source, module_dirs) + synthetic_prebundle_entry( + specifier, + filename, + source, + context_dir(module_dirs, plugins, module_types) + ) {:proxy, filename, _opts} = entry -> synthetic_prebundle_entry( specifier, filename, Volt.JS.PrebundleEntry.source(entry), - module_dirs + context_dir(module_dirs, plugins, module_types) ) nil -> @@ -371,8 +381,8 @@ defmodule Volt.JS.Vendor do end end - defp synthetic_prebundle_entry(specifier, filename, source, _module_dirs) do - dir = Path.expand(Path.join([cache_dir(), "entries", encode_specifier(specifier)])) + defp synthetic_prebundle_entry(specifier, filename, source, directory) do + dir = Path.expand(Path.join([directory, "entries", encode_specifier(specifier)])) path = Path.join(dir, filename) File.mkdir_p!(dir) File.write!(path, source) @@ -514,13 +524,13 @@ defmodule Volt.JS.Vendor do defp project_root([module_dir | _]), do: Path.dirname(module_dir) defp project_root([]), do: File.cwd!() - defp ensure_cache_dir do - File.mkdir_p!(cache_dir()) + defp ensure_cache_dir(module_dirs, plugins, module_types) do + File.mkdir_p!(context_dir(module_dirs, plugins, module_types)) :ok end - defp read_cached("chunks/" <> _ = specifier) do - Path.join(cache_dir(), specifier <> ".js") + defp read_cached("chunks/" <> _ = specifier, directory) do + Path.join(directory, specifier <> ".js") |> File.read() |> case do {:ok, _} = ok -> ok @@ -528,9 +538,9 @@ defmodule Volt.JS.Vendor do end end - defp read_cached(specifier) do - specifier - |> cache_path() + defp read_cached(specifier, directory) do + directory + |> Path.join(encode_specifier(specifier) <> ".js") |> File.read() |> case do {:ok, _} = ok -> ok @@ -539,13 +549,13 @@ defmodule Volt.JS.Vendor do end defp cache_fresh?(specifier, module_dirs, plugins, module_types) do - File.regular?(cache_path(specifier)) and - File.read(cache_meta_path(specifier)) == + File.regular?(cache_path(specifier, module_dirs, plugins, module_types)) and + File.read(cache_meta_path(specifier, module_dirs, plugins, module_types)) == {:ok, cache_signature(specifier, module_dirs, plugins, module_types)} end defp write_cache_files!(output_path, code, specifier, module_dirs, plugins, module_types) do - meta_path = cache_meta_path(specifier) + meta_path = cache_meta_path(specifier, module_dirs, plugins, module_types) nonce = System.unique_integer([:positive]) tmp_output = "#{output_path}.#{nonce}.tmp" tmp_meta = "#{meta_path}.#{nonce}.tmp" @@ -651,11 +661,15 @@ defmodule Volt.JS.Vendor do end end - defp cache_path(specifier) do - Path.join(cache_dir(), encode_specifier(specifier) <> ".js") + defp cache_path(specifier, module_dirs, plugins, module_types) do + Path.join( + context_dir(module_dirs, plugins, module_types), + encode_specifier(specifier) <> ".js" + ) end - defp cache_meta_path(specifier), do: cache_path(specifier) <> ".meta" + defp cache_meta_path(specifier, module_dirs, plugins, module_types), + do: cache_path(specifier, module_dirs, plugins, module_types) <> ".meta" @doc "Encode a specifier for use in URLs (escaping @ and /)." def encode_specifier(specifier) do diff --git a/lib/volt/plugin/vue.ex b/lib/volt/plugin/vue.ex index 2abfaca..999bc10 100644 --- a/lib/volt/plugin/vue.ex +++ b/lib/volt/plugin/vue.ex @@ -1,6 +1,8 @@ defmodule Volt.Plugin.Vue do @behaviour Volt.Plugin + alias Vize.SFC + @impl true def name, do: "vue" @@ -22,7 +24,10 @@ defmodule Volt.Plugin.Vue do {base_path, query} = Volt.URL.split_query(path) if query == "" and Path.extname(base_path) == ".vue" do + scope_id = SFC.scope_id(base_path) + sfc_opts = [ + scope_id: scope_id, filename: base_path, vapor: Keyword.get(opts, :vapor, false), strip_types: true, @@ -30,16 +35,22 @@ defmodule Volt.Plugin.Vue do source_map: Keyword.get(opts, :sourcemap, true) ] - with {:ok, assets} <- Vize.SFC.collect_template_assets(source, filename: base_path), + with {:ok, descriptor} <- Vize.parse_sfc(source), + {:ok, assets} <- SFC.collect_template_assets(source, filename: base_path), {:ok, result} <- Vize.compile_sfc(source, sfc_opts) do + assembled = + result.code + |> assemble_template_component(descriptor, sfc_opts[:vapor]) + |> attach_scope(descriptor, scope_id, sfc_opts[:vapor]) + {:ok, %Volt.Pipeline.Result{ code: - result.code - |> Vize.SFC.rewrite_asset_references(assets) + assembled + |> SFC.rewrite_asset_references(assets) |> append_template_asset_imports(assets) |> maybe_append_style_imports(path, source, opts), - sourcemap: result.source_map, + sourcemap: if(assembled == result.code, do: result.source_map), css: result.css, hashes: %Volt.Pipeline.Result.Hashes{ template: result.template_hash, @@ -51,6 +62,38 @@ defmodule Volt.Plugin.Vue do end end + defp assemble_template_component( + code, + %{script: nil, script_setup: nil, template: template}, + false + ) + when not is_nil(template), do: code <> "\nexport default { render };\n" + + defp assemble_template_component(code, _descriptor, _vapor), do: code + + defp attach_scope(code, %{styles: styles}, scope_id, false) do + if Enum.any?(styles, & &1.scoped) do + {:ok, %{body: nodes}} = OXC.parse(code, "component.js") + + patches = + for %{type: :export_default_declaration, declaration: declaration} <- nodes do + %{start: first, end: last} = declaration + expression = binary_part(code, first, last - first) + + value = + "((component) => { component.__scopeId = #{Jason.encode!("data-v-" <> scope_id)}; return component; })(#{expression})" + + Volt.JS.Patch.new(first, last, value) + end + + Volt.JS.Patch.apply(code, patches) + else + code + end + end + + defp attach_scope(code, _descriptor, _scope_id, _vapor), do: code + @impl true def extract_imports(path, source, _opts) do if Path.extname(path) == ".vue" do diff --git a/lib/volt/preload.ex b/lib/volt/preload.ex index fc1b4b5..0e908b3 100644 --- a/lib/volt/preload.ex +++ b/lib/volt/preload.ex @@ -63,8 +63,14 @@ defmodule Volt.Preload do chunk |> Map.get("imports", []) |> Enum.reject(&MapSet.member?(seen, &1)) - |> Enum.flat_map(fn file -> - [file | imported_files(manifest, manifest[file] || %{}, MapSet.put(seen, file))] + |> Enum.flat_map(fn key -> + case Map.get(manifest, key) do + %{"file" => file} = imported -> + [file | imported_files(manifest, imported, MapSet.put(seen, key))] + + _ -> + [] + end end) |> js_files() end diff --git a/lib/volt/public_dir.ex b/lib/volt/public_dir.ex index 0415c49..b95ebc1 100644 --- a/lib/volt/public_dir.ex +++ b/lib/volt/public_dir.ex @@ -37,6 +37,44 @@ defmodule Volt.PublicDir do :ok end + @doc "Read public files into artifacts relative to the static root without copying them." + def prepare(nil), do: [] + + def prepare(public_dir) do + if File.dir?(public_dir) do + Enum.map(files(public_dir), fn path -> + %Volt.Builder.Artifact{ + file: Path.relative_to(path, public_dir), + content: File.read!(path) + } + end) + else + [] + end + end + + @doc "Combine asset output and public files in a validated static-root plan." + def prepare_output(plan, outdir, public_dir, publication_root) do + outdir = Path.expand(outdir) + static_root = Path.expand(publication_root) + prefix = Path.relative_to(outdir, static_root) + + if outdir == static_root or Volt.Path.inside?(outdir, static_root) do + assets = + Enum.map(plan.artifacts, fn artifact -> + if prefix == ".", + do: artifact, + else: %{artifact | file: Path.join(prefix, artifact.file)} + end) + + with {:ok, combined} <- Volt.Builder.Plan.new(assets ++ prepare(public_dir)) do + {:ok, static_root, combined} + end + else + {:error, {:invalid_publication_root, static_root, outdir}} + end + end + @doc """ Resolves a root-relative request path inside `public_dir`. diff --git a/lib/volt/tailwind.ex b/lib/volt/tailwind.ex index d162271..0166f6c 100644 --- a/lib/volt/tailwind.ex +++ b/lib/volt/tailwind.ex @@ -1,198 +1,41 @@ defmodule Volt.Tailwind do @moduledoc """ - Tailwind CSS integration — scan source files for candidates and compile CSS. + Tailwind CSS build contexts backed by Oxide and QuickBEAM. - Uses Oxide for fast parallel content scanning and QuickBEAM to run - the Tailwind CSS compiler. No Node.js or CLI required. - - ## Usage - - # In your config: - config :volt, :tailwind, - sources: [ - %{base: "lib/", pattern: "**/*.{ex,heex}"}, - %{base: "assets/", pattern: "**/*.{vue,ts,tsx}"} - ] - - # Generate CSS: - {:ok, css} = Volt.Tailwind.build() - - ## Tailwind directives - - Volt supports Tailwind's CSS-first directives like `@theme`, `@source`, - `@utility`, `@variant`, and `@apply` through the Tailwind compiler. - - Volt also resolves local `@import`, `@reference`, `@plugin`, and `@config` - files inside QuickBEAM, plus installed package plugins like - `@tailwindcss/typography`. + Each CSS root/profile pair owns an isolated scanner and generated CSS state. + This keeps incremental rebuilds independent across applications and profiles. """ - use GenServer - - def start_link(opts \\ []) do - GenServer.start_link(__MODULE__, opts, name: __MODULE__) - end - - @doc """ - Compile Tailwind CSS from scanned candidates. - - Scans all configured source directories for Tailwind class candidates, - then runs the Tailwind compiler to generate CSS. - - ## Options + @default_key {:default, :inline} - * `:css` — custom input CSS (default: Tailwind's base with theme + preflight + utilities) - * `:css_base` — base directory for resolving local `@import`, `@reference`, `@plugin`, and `@config` paths - * `:sources` — override source patterns (default: from config) - * `:minify` — minify the output CSS (default: `false`) - """ @spec build(keyword()) :: {:ok, String.t()} | {:error, term()} def build(opts \\ []) do - GenServer.call(__MODULE__, {:build, opts}, :infinity) + opts + |> worker() + |> GenServer.call({:build, opts}, :infinity) end - @doc """ - Incremental build — only process changed files and return CSS if new candidates found. - - Returns `{:ok, css}` if new candidates were found, `:unchanged` otherwise. - """ - @spec rebuild(list(), keyword()) :: {:ok, String.t()} | :unchanged | {:error, term()} + @spec rebuild([String.t() | map()], keyword()) :: + {:ok, String.t()} | :unchanged | {:error, term()} def rebuild(changed_files, opts \\ []) do - GenServer.call(__MODULE__, {:rebuild, changed_files, opts}, :infinity) - end - - @impl true - def init(opts) do - sources = opts[:sources] || Volt.Config.tailwind()[:sources] || [] - - {:ok, - %{ - runtime: nil, - scanner: nil, - sources: sources, - last_css: nil - }} + opts + |> worker() + |> GenServer.call({:rebuild, changed_files, opts}, :infinity) end - defp ensure_runtime(%{runtime: nil} = state) do - runtime = - Volt.JS.Runtime.PackageSet.runtime_opts( - Volt.Tailwind.Loader.runtime_package_set(), - apis: [:browser, :node], - handlers: fn runtime -> Volt.Tailwind.Loader.handlers(runtime.node_modules) end, - define: fn runtime -> - %{ - "TAILWIND_ROOT" => Volt.JS.Runtime.package_path!(runtime, "tailwindcss"), - "TAILWIND_DEFAULT_BASE" => File.cwd!() - } - end, - entry: {:volt_asset, "compilers/tailwind.ts"} - ) - |> Volt.JS.Runtime.ensure!() - - %{state | runtime: runtime, scanner: build_scanner(state.sources)} - end - - defp ensure_runtime(state), do: state - - @impl true - def handle_call({:build, opts}, _from, state) do - state = ensure_runtime(state) - css_input = opts[:css] - minify = Keyword.get(opts, :minify, false) - css_base = opts[:css_base] || File.cwd!() - sources = opts[:sources] || state.sources - - scanner = - if(opts[:sources], - do: build_scanner(sources), - else: state.scanner || Oxide.new(sources: []) - ) - - case compile_css(state.runtime, css_input, Oxide.scan(scanner), css_base) do - {:ok, css} -> - css = maybe_minify(css, minify) - {:reply, {:ok, css}, %{state | scanner: scanner, last_css: css}} - - {:error, _} = error -> - {:reply, error, state} + defp worker(opts) do + case Keyword.fetch(opts, :worker) do + {:ok, worker} -> worker + :error -> registered_worker(opts) end end - def handle_call({:rebuild, changed_files, opts}, _from, state) do - state = ensure_runtime(state) - minify = Keyword.get(opts, :minify, false) - css_input = opts[:css] - css_base = opts[:css_base] || File.cwd!() - - changed = - Enum.map(changed_files, fn - path when is_binary(path) -> - %Oxide.Changed{file: path, extension: Path.extname(path) |> String.trim_leading(".")} - - map -> - struct!(Oxide.Changed, map) - end) - - case state.scanner do - nil -> - {:reply, {:error, :no_scanner}, state} - - scanner -> - if Oxide.scan_files(scanner, changed) == [] do - {:reply, :unchanged, state} - else - case compile_css(state.runtime, css_input, Oxide.scan(scanner), css_base) do - {:ok, css} -> - css = maybe_minify(css, minify) - {:reply, {:ok, css}, %{state | last_css: css}} - - {:error, _} = error -> - {:reply, error, state} - end - end - end - end - - @impl true - def terminate(_reason, %{runtime: nil}), do: :ok - - def terminate(_reason, %{runtime: runtime}) do - Volt.JS.Runtime.stop(runtime) - :ok - end - - defp build_scanner([]), do: nil - - defp build_scanner(sources) do - oxide_sources = Enum.map(sources, &to_oxide_source/1) - Oxide.new(sources: oxide_sources) - end - - defp compile_css(runtime, css_input, candidates, css_base) do - case Volt.JS.Runtime.call(runtime, "compileTailwindCss", [ - css_input, - candidates, - Path.expand(css_base) - ]) do - {:ok, css} when is_binary(css) -> {:ok, css} - {:ok, _} -> {:error, :unexpected_result} - {:error, _} = error -> error - end - end - - defp maybe_minify(css, false), do: css - - defp maybe_minify(css, true) do - {:ok, %{code: minified}} = Vize.CSS.compile(css, minify: true) - minified - end + defp registered_worker(opts) do + key = Keyword.get(opts, :key, @default_key) - defp to_oxide_source(%{base: base, pattern: pattern} = source) do - %Oxide.Source{ - base: Path.expand(base), - pattern: pattern, - negated: Map.get(source, :negated, false) - } + Volt.Tailwind.Supervisor.worker(key, + sources: Keyword.get(opts, :sources, []), + runtime: Keyword.get(opts, :runtime) + ) end end diff --git a/lib/volt/tailwind/artifact.ex b/lib/volt/tailwind/artifact.ex new file mode 100644 index 0000000..906791a --- /dev/null +++ b/lib/volt/tailwind/artifact.ex @@ -0,0 +1,21 @@ +defmodule Volt.Tailwind.Artifact do + @moduledoc false + + @spec write(String.t() | nil, String.t(), String.t()) :: :ok + def write(nil, _name, _css), do: :ok + + def write(outdir, name, css) do + File.mkdir_p!(outdir) + path = Path.join(outdir, "#{name}.css") + temporary = path <> ".#{System.unique_integer([:positive])}.tmp" + + with :ok <- File.write(temporary, css), + :ok <- File.rename(temporary, path) do + :ok + else + {:error, reason} -> + File.rm(temporary) + raise File.Error, reason: reason, action: "write", path: path + end + end +end diff --git a/lib/volt/tailwind/build.ex b/lib/volt/tailwind/build.ex new file mode 100644 index 0000000..e7801a3 --- /dev/null +++ b/lib/volt/tailwind/build.ex @@ -0,0 +1,57 @@ +defmodule Volt.Tailwind.Build do + @moduledoc "Builds a configured Tailwind root into a Volt asset result." + + @spec build(Volt.Config.Tailwind.t(), keyword()) :: + {:ok, Volt.Builder.Result.t()} | {:error, term()} + def build(%Volt.Config.Tailwind{} = root, opts) do + with {:ok, result, plan} <- prepare(root, opts), + :ok <- Volt.Builder.Writer.write_plan(Keyword.fetch!(opts, :outdir), plan) do + {:ok, result} + end + end + + @doc "Compile a Tailwind root and prepare output without writing assets." + @spec prepare(Volt.Config.Tailwind.t(), keyword()) :: + {:ok, Volt.Builder.Result.t(), Volt.Builder.Plan.t()} | {:error, term()} + def prepare(%Volt.Config.Tailwind{} = root, opts) do + with {:ok, css_input} <- read_css(root.css), + {:ok, css} <- + Volt.Tailwind.Runtime.compile_once( + css_input, + [], + css_base(root.css), + %{ + base: Path.expand(Keyword.get(opts, :root, File.cwd!())), + sources: scan_sources(root.sources) + } + ) do + Volt.Builder.Writer.prepare_style_entry( + root.name, + css, + Keyword.fetch!(opts, :outdir), + Keyword.get(opts, :hash, true), + root.css, + minify: Keyword.get(opts, :minify, true), + root: Keyword.get(opts, :root, File.cwd!()), + asset_url_prefix: Keyword.get(opts, :asset_url_prefix, Volt.Paths.prefix()) + ) + end + end + + defp scan_sources(sources) do + sources + |> Enum.map(fn source -> + %{ + base: Path.expand(source.base), + pattern: source.pattern, + negated: Map.get(source, :negated, false) + } + end) + end + + defp read_css(nil), do: {:ok, nil} + defp read_css(path), do: File.read(path) + + defp css_base(nil), do: File.cwd!() + defp css_base(path), do: Path.dirname(path) +end diff --git a/lib/volt/tailwind/compilation.ex b/lib/volt/tailwind/compilation.ex new file mode 100644 index 0000000..33401a0 --- /dev/null +++ b/lib/volt/tailwind/compilation.ex @@ -0,0 +1,21 @@ +defmodule Volt.Tailwind.Compilation do + @moduledoc "Retained stylesheet compilation inputs for a Tailwind context." + + defstruct css: nil, base: nil, minify: false + + def new(opts) do + %__MODULE__{ + css: Keyword.get(opts, :css), + base: Path.expand(Keyword.get(opts, :css_base) || File.cwd!()), + minify: Keyword.get(opts, :minify, false) + } + end + + def update(%__MODULE__{} = compilation, opts) do + new( + css: Keyword.get(opts, :css, compilation.css), + css_base: Keyword.get(opts, :css_base, compilation.base), + minify: Keyword.get(opts, :minify, compilation.minify) + ) + end +end diff --git a/lib/volt/tailwind/compile_error.ex b/lib/volt/tailwind/compile_error.ex new file mode 100644 index 0000000..883686a --- /dev/null +++ b/lib/volt/tailwind/compile_error.ex @@ -0,0 +1,4 @@ +defmodule Volt.Tailwind.CompileError do + @moduledoc "Compiler failure retaining dependencies needed to observe recovery." + defexception [:message, dependencies: []] +end diff --git a/lib/volt/tailwind/context.ex b/lib/volt/tailwind/context.ex new file mode 100644 index 0000000..7cbfa8f --- /dev/null +++ b/lib/volt/tailwind/context.ex @@ -0,0 +1,5 @@ +defmodule Volt.Tailwind.Context do + @moduledoc "A compiler handle tied to one QuickBEAM runtime generation." + @enforce_keys [:runtime, :id] + defstruct [:runtime, :id] +end diff --git a/lib/volt/tailwind/loader.ex b/lib/volt/tailwind/loader.ex index 2a8a723..8e550ab 100644 --- a/lib/volt/tailwind/loader.ex +++ b/lib/volt/tailwind/loader.ex @@ -16,50 +16,71 @@ defmodule Volt.Tailwind.Loader do def handlers(runtime_node_modules) do %{ - "tailwind.load_stylesheet" => fn [id, base] -> - load_stylesheet(id, base, runtime_node_modules) + "tailwind.scan" => fn [sources] -> + sources + |> Enum.map(fn %{"base" => base, "pattern" => pattern, "negated" => negated} -> + %Oxide.Source{base: Path.expand(base), pattern: pattern, negated: negated} + end) + |> then(&Oxide.new(sources: &1)) + |> Oxide.scan() + end, + "tailwind.load_stylesheet" => fn [id, base, output_base] -> + capture_resolution(fn -> load_stylesheet(id, base, output_base, runtime_node_modules) end) end, "tailwind.load_module" => fn [id, base, kind] -> - load_module(id, base, kind, runtime_node_modules) + capture_resolution(fn -> load_module(id, base, kind, runtime_node_modules) end) end } end - defp load_stylesheet(id, base, runtime_node_modules) do + defp capture_resolution(load) do + load.() + rescue + error in Volt.Tailwind.ResolveError -> + %{error: Exception.message(error), candidates: error.candidates} + end + + defp load_stylesheet(id, base, output_base, runtime_node_modules) do path = Resolver.resolve_stylesheet_path!(id, base, runtime_node_modules) + {:ok, content} = Volt.CSS.AssetURLRewriter.rebase(File.read!(path), path, output_base) %{ + path: path, base: Path.dirname(path), - content: File.read!(path) + content: content } end defp load_module(id, base, kind, runtime_node_modules) do path = Resolver.resolve_module_path!(id, base, kind, runtime_node_modules) - {code, format} = + {code, format, dependencies} = if Path.extname(path) == ".json" do - {File.read!(path), "json"} + {File.read!(path), "json", [path]} else - {bundle_module_source!(path, runtime_node_modules), "cjs"} + {code, dependencies} = bundle_module_source!(path, runtime_node_modules) + {code, "cjs", dependencies} end %{ path: path, base: Path.dirname(path), code: code, + dependencies: dependencies, format: format } end defp bundle_module_source!(entry_path, runtime_node_modules) do with {:ok, files} <- collect_bundle_files(entry_path, runtime_node_modules) do + dependencies = Enum.map(files, fn {path, _source} -> path end) + case OXC.bundle(files, entry: entry_path, format: :cjs) do {:ok, code} when is_binary(code) -> - code + {code, dependencies} {:ok, %{code: code}} when is_binary(code) -> - code + {code, dependencies} {:error, errors} -> raise "Could not bundle Tailwind module #{inspect(entry_path)}: #{inspect(errors)}" diff --git a/lib/volt/tailwind/metadata.ex b/lib/volt/tailwind/metadata.ex new file mode 100644 index 0000000..9f67932 --- /dev/null +++ b/lib/volt/tailwind/metadata.ex @@ -0,0 +1,28 @@ +defmodule Volt.Tailwind.Metadata do + @moduledoc "Compiler-discovered inputs decoded at the JavaScript boundary." + + defstruct [:code, :root, dependencies: [], sources: []] + + def decode(%{ + "code" => code, + "root" => root, + "dependencies" => dependencies, + "sources" => sources + }) do + %__MODULE__{ + code: code, + root: root(root), + dependencies: dependencies, + sources: + Enum.map(sources, fn %{"base" => base, "pattern" => pattern, "negated" => negated} -> + %Oxide.Source{base: base, pattern: pattern, negated: negated} + end) + } + end + + defp root(nil), do: :automatic + defp root("none"), do: :none + + defp root(%{"base" => base, "pattern" => pattern}), + do: %Oxide.Source{base: base, pattern: pattern, negated: false} +end diff --git a/lib/volt/tailwind/resolve_error.ex b/lib/volt/tailwind/resolve_error.ex new file mode 100644 index 0000000..a4bbacb --- /dev/null +++ b/lib/volt/tailwind/resolve_error.ex @@ -0,0 +1,4 @@ +defmodule Volt.Tailwind.ResolveError do + @moduledoc "A failed file resolution with the exact candidates attempted by the resolver." + defexception [:message, candidates: []] +end diff --git a/lib/volt/tailwind/resolver.ex b/lib/volt/tailwind/resolver.ex index 4c12414..81aef74 100644 --- a/lib/volt/tailwind/resolver.ex +++ b/lib/volt/tailwind/resolver.ex @@ -53,10 +53,13 @@ defmodule Volt.Tailwind.Resolver do defp resolve_bare_path!(id, base, extensions, index_files, kind, runtime_node_modules) do {package_name, subpath} = NPM.Resolution.PackageResolver.split_specifier(id) - resolved = + search_dirs = ([find_node_modules_for(base), runtime_node_modules] ++ phoenix_tailwind_search_dirs()) |> Enum.reject(&is_nil/1) |> Enum.uniq() + + resolved = + search_dirs |> Enum.find_value(fn node_modules -> package_dir = Path.join(node_modules, package_name) @@ -80,7 +83,26 @@ defmodule Volt.Tailwind.Resolver do Path.expand(path) nil -> - raise "Could not resolve #{kind} #{inspect(id)} from #{inspect(base)}. Add it to node_modules or the Tailwind runtime install." + candidates = + Enum.flat_map(search_dirs, fn directory -> + package = Path.join(directory, package_name) + + target = + Path.join( + package, + if(subpath, do: String.trim_leading(subpath, "./"), else: "index") + ) + + [ + Path.join(package, "package.json") + | Enum.map(extensions ++ index_files, &(target <> &1)) + ] + end) + + raise Volt.Tailwind.ResolveError, + message: + "Could not resolve #{kind} #{inspect(id)} from #{inspect(base)}. Add it to node_modules or the Tailwind runtime install.", + candidates: candidates end end @@ -120,7 +142,10 @@ defmodule Volt.Tailwind.Resolver do Path.expand(path) {:error, reason} -> - raise "Could not resolve file #{inspect(id)} from #{inspect(base)}: #{inspect(reason)}" + raise Volt.Tailwind.ResolveError, + message: + "Could not resolve file #{inspect(id)} from #{inspect(base)}: #{inspect(reason)}", + candidates: Enum.map(extensions ++ index_files, &(target <> &1)) end end diff --git a/lib/volt/tailwind/runtime.ex b/lib/volt/tailwind/runtime.ex new file mode 100644 index 0000000..13d8926 --- /dev/null +++ b/lib/volt/tailwind/runtime.ex @@ -0,0 +1,153 @@ +defmodule Volt.Tailwind.Runtime do + @moduledoc false + + use GenServer + + @name __MODULE__ + + def start_link(opts) do + GenServer.start_link(__MODULE__, :ok, name: Keyword.get(opts, :name, @name)) + end + + @spec call(String.t() | nil, [String.t()], String.t()) :: {:ok, String.t()} | {:error, term()} + def call(css, candidates, css_base, server \\ @name) do + GenServer.call(server, {:compile, css, candidates, css_base}, :infinity) + end + + def prepare_context(css, base, server), + do: GenServer.call(server, {:prepare_context, css, base}, :infinity) + + def build_context(context, candidates, server), + do: GenServer.call(server, {:build_context, context, candidates}, :infinity) + + def release_context(context, server), + do: GenServer.call(server, {:release_context, context}, :infinity) + + @doc "Compile and return source/dependency metadata for development invalidation." + def compile_metadata(css, candidates, css_base, server) do + GenServer.call(server, {:compile_metadata, css, candidates, css_base}, :infinity) + end + + @doc "Compile once in an isolated runtime, releasing it when compilation finishes." + @spec compile_once(String.t() | nil, [String.t()], String.t()) :: + {:ok, String.t()} | {:error, term()} + def compile_once(css, candidates, css_base, scan \\ nil) do + runtime = start_runtime() + + try do + runtime + |> Volt.JS.Runtime.call("compileTailwindCss", [css, candidates, css_base, scan]) + |> normalize_result() + after + if Process.alive?(runtime.pid), do: Volt.JS.Runtime.stop(runtime) + end + end + + @impl true + def init(:ok) do + Process.flag(:trap_exit, true) + {:ok, nil} + end + + @impl true + def handle_call({:prepare_context, css, base}, _from, runtime) do + runtime = if runtime && Process.alive?(runtime.pid), do: runtime, else: start_runtime() + + case invoke(runtime, "prepareTailwindContext", [css, base]) do + {:ok, %{"error" => message, "dependencies" => dependencies}} -> + {:reply, + {:error, %Volt.Tailwind.CompileError{message: message, dependencies: dependencies}}, + runtime} + + {:ok, %{"id" => id} = metadata} -> + {:reply, + {:ok, %Volt.Tailwind.Context{runtime: runtime.pid, id: id}, + Volt.Tailwind.Metadata.decode(metadata)}, runtime} + + error -> + {:reply, error, runtime} + end + end + + def handle_call( + {:build_context, %Volt.Tailwind.Context{} = context, candidates}, + _from, + runtime + ) do + if runtime && runtime.pid == context.runtime && Process.alive?(runtime.pid) do + {:reply, invoke(runtime, "buildTailwindContext", [context.id, candidates]), runtime} + else + {:reply, {:error, :stale_compiler_context}, runtime} + end + end + + def handle_call({:release_context, context}, _from, runtime) do + if runtime && runtime.pid == context.runtime && Process.alive?(runtime.pid) do + invoke(runtime, "releaseTailwindContext", [context.id]) + end + + {:reply, :ok, runtime} + end + + def handle_call({:compile_metadata, css, candidates, css_base}, _from, runtime) do + execute(runtime, [css, candidates, css_base, nil, true], fn + {:ok, result} -> {:ok, Volt.Tailwind.Metadata.decode(result)} + {:error, _} = error -> error + end) + end + + def handle_call({:compile, css, candidates, css_base}, _from, runtime) do + execute(runtime, [css, candidates, css_base], &normalize_result/1) + end + + defp execute(runtime, args, decode) do + runtime = if runtime && Process.alive?(runtime.pid), do: runtime, else: start_runtime() + + try do + result = Volt.JS.Runtime.call(runtime, "compileTailwindCss", args) + {:reply, decode.(result), runtime} + catch + :exit, reason -> + if Process.alive?(runtime.pid), do: Volt.JS.Runtime.stop(runtime) + {:reply, {:error, {:compiler_exit, reason}}, nil} + end + end + + @impl true + def handle_info({:EXIT, pid, _reason}, %{pid: pid}), do: {:noreply, nil} + def handle_info({:EXIT, _pid, _reason}, runtime), do: {:noreply, runtime} + + @impl true + def terminate(_reason, nil), do: :ok + + def terminate(_reason, runtime) do + if Process.alive?(runtime.pid), do: Volt.JS.Runtime.stop(runtime) + :ok + end + + defp invoke(runtime, function, args) do + Volt.JS.Runtime.call(runtime, function, args) + catch + :exit, reason -> {:error, {:compiler_exit, reason}} + end + + defp start_runtime do + Volt.JS.Runtime.PackageSet.runtime_opts( + Volt.Tailwind.Loader.runtime_package_set(), + apis: [:browser, :node], + handlers: fn runtime -> Volt.Tailwind.Loader.handlers(runtime.node_modules) end, + define: fn runtime -> + %{ + "TAILWIND_ROOT" => Volt.JS.Runtime.package_path!(runtime, "tailwindcss"), + "TAILWIND_DEFAULT_BASE" => File.cwd!() + } + end, + entry: {:volt_asset, "compilers/tailwind.ts"} + ) + |> Volt.JS.Runtime.ensure!() + end + + defp normalize_result({:ok, result}) when is_binary(result), do: {:ok, result} + defp normalize_result({:ok, _result}), do: {:error, :unexpected_result} + defp normalize_result({:error, _reason} = error), do: error +end diff --git a/lib/volt/tailwind/state.ex b/lib/volt/tailwind/state.ex new file mode 100644 index 0000000..fee6ac6 --- /dev/null +++ b/lib/volt/tailwind/state.ex @@ -0,0 +1,16 @@ +defmodule Volt.Tailwind.State do + @moduledoc false + + @enforce_keys [:key, :sources] + defstruct [ + :key, + :scanner, + :last_css, + :sources, + :compilation, + :runtime, + :context, + configured_sources: [], + dependencies: [] + ] +end diff --git a/lib/volt/tailwind/supervisor.ex b/lib/volt/tailwind/supervisor.ex new file mode 100644 index 0000000..f205b81 --- /dev/null +++ b/lib/volt/tailwind/supervisor.ex @@ -0,0 +1,55 @@ +defmodule Volt.Tailwind.Supervisor do + @moduledoc false + + @registry Volt.Tailwind.Registry + @supervisor Volt.Tailwind.WorkerSupervisor + + @doc "Release a compiler context without affecting other registered roots." + def release(key) do + case Registry.lookup(@registry, key) do + [] -> :ok + [{pid, _}] -> DynamicSupervisor.terminate_child(@supervisor, pid) + end + end + + @doc "Resolve a lazy runtime shared only by contexts in one explicit session." + def runtime({:session, session, _key}) do + key = {:runtime, session} + name = {:via, Registry, {@registry, key}} + + case Registry.lookup(@registry, key) do + [{pid, _}] -> + pid + + [] -> + case DynamicSupervisor.start_child(@supervisor, {Volt.Tailwind.Runtime, [name: name]}) do + {:ok, pid} -> pid + {:error, {:already_started, pid}} -> pid + end + end + end + + def runtime(_key), do: Volt.Tailwind.Runtime + + @doc "Release the compiler runtime owned by an explicit session." + def release_runtime(session), do: release({:runtime, session}) + + @spec worker(term(), keyword()) :: GenServer.server() + def worker(key, opts) do + name = {:via, Registry, {@registry, key}} + + case Registry.lookup(@registry, key) do + [{pid, _value}] -> + pid + + [] -> + spec = {Volt.Tailwind.Worker, Keyword.merge(opts, key: key, name: name)} + + case DynamicSupervisor.start_child(@supervisor, spec) do + {:ok, pid} -> pid + {:error, {:already_started, pid}} -> pid + {:error, reason} -> raise "could not start Tailwind worker: #{inspect(reason)}" + end + end + end +end diff --git a/lib/volt/tailwind/worker.ex b/lib/volt/tailwind/worker.ex new file mode 100644 index 0000000..4201b96 --- /dev/null +++ b/lib/volt/tailwind/worker.ex @@ -0,0 +1,237 @@ +defmodule Volt.Tailwind.Worker do + @moduledoc false + + use GenServer + + alias Volt.Tailwind.{State, Runtime} + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: Keyword.fetch!(opts, :name)) + end + + @impl true + def init(opts) do + Process.flag(:trap_exit, true) + + {:ok, + %State{ + key: Keyword.fetch!(opts, :key), + runtime: Keyword.get(opts, :runtime), + scanner: nil, + last_css: nil, + configured_sources: Keyword.get(opts, :sources, []), + sources: Keyword.get(opts, :sources, []) + }} + end + + @doc "Return the last successfully compiled stylesheet without compiling." + def stylesheet(server), do: GenServer.call(server, :stylesheet) + + def inputs(server), do: GenServer.call(server, :inputs) + + @impl true + def handle_call(:inputs, _from, state), + do: {:reply, %{sources: state.sources, dependencies: state.dependencies}, state} + + def handle_call(:stylesheet, _from, state) do + result = if is_binary(state.last_css), do: {:ok, state.last_css}, else: {:error, :not_built} + {:reply, result, state} + end + + def handle_call({:build, opts}, _from, state) do + sources = Keyword.get(opts, :sources, state.configured_sources) + compilation = Volt.Tailwind.Compilation.new(opts) + runtime = state.runtime || Volt.Tailwind.Supervisor.runtime(state.key) + state = %{state | runtime: runtime} + + with {:ok, context, metadata} <- + Runtime.prepare_context(compilation.css, compilation.base, runtime) do + configured = sources + sources = effective_sources(metadata, configured, compilation.base) + scanner = build_scanner(sources) + + case finish_build( + state, + compilation, + scanner, + sources, + metadata.dependencies, + context, + runtime + ) do + {:reply, {:ok, css}, updated} -> + {:reply, {:ok, css}, %{updated | configured_sources: configured}} + + error -> + error + end + else + {:error, %Volt.Tailwind.CompileError{dependencies: dependencies}} = error -> + {:reply, error, %{state | dependencies: Enum.uniq(state.dependencies ++ dependencies)}} + + {:error, _} = error -> + {:reply, error, state} + end + end + + def handle_call({:rebuild, changed_files, opts}, _from, state) do + case state.scanner do + nil when not is_nil(state.compilation) -> + compilation = Volt.Tailwind.Compilation.update(state.compilation, opts) + runtime = state.runtime || Volt.Tailwind.Supervisor.runtime(state.key) + restore_context(state, compilation, runtime) + + nil -> + {:reply, {:error, :no_scanner}, state} + + scanner -> + rebuild_css(state, scanner, changed_files, opts) + end + end + + defp finish_build(state, compilation, scanner, sources, dependencies, context, runtime) do + case Runtime.build_context(context, scan(scanner), runtime) do + {:ok, css} -> + css = maybe_minify(css, compilation.minify) + + if state.context, do: Runtime.release_context(state.context, runtime) + + {:reply, {:ok, css}, + %{ + state + | context: context, + scanner: scanner, + last_css: css, + sources: sources, + compilation: compilation, + dependencies: dependencies + }} + + {:error, _reason} = error -> + Runtime.release_context(context, runtime) + {:reply, error, state} + end + end + + defp rebuild_css(state, scanner, changed_files, opts) do + changed = Enum.map(changed_files, &changed_file/1) + + new_candidates = Oxide.scan_files(scanner, changed) + compilation = Volt.Tailwind.Compilation.update(state.compilation, opts) + stale? = is_nil(state.context) or not Process.alive?(state.context.runtime) + + if new_candidates == [] and not stale? and compilation == state.compilation do + {:reply, :unchanged, state} + else + compile_rebuilt_css(state, scanner, opts) + end + end + + defp compile_rebuilt_css(state, scanner, opts) do + compilation = Volt.Tailwind.Compilation.update(state.compilation, opts) + + runtime = state.runtime || Volt.Tailwind.Supervisor.runtime(state.key) + candidates = Oxide.scan(scanner) + + result = + if state.context && compilation == state.compilation do + Runtime.build_context(state.context, candidates, runtime) + else + {:error, :stale_compiler_context} + end + + case result do + {:ok, css} -> + css = maybe_minify(css, compilation.minify) + reply = if css == state.last_css, do: :unchanged, else: {:ok, css} + {:reply, reply, %{state | last_css: css, compilation: compilation}} + + {:error, :stale_compiler_context} -> + restore_context(state, compilation, runtime) + + {:error, _reason} = error -> + {:reply, error, state} + end + end + + defp restore_context(state, compilation, runtime) do + with {:ok, context, metadata} <- + Runtime.prepare_context(compilation.css, compilation.base, runtime) do + sources = effective_sources(metadata, state.configured_sources, compilation.base) + scanner = build_scanner(sources) + + case Runtime.build_context(context, scan(scanner), runtime) do + {:ok, css} -> + css = maybe_minify(css, compilation.minify) + if state.context, do: Runtime.release_context(state.context, runtime) + reply = if css == state.last_css, do: :unchanged, else: {:ok, css} + + {:reply, reply, + %{ + state + | context: context, + scanner: scanner, + sources: sources, + compilation: compilation, + last_css: css, + dependencies: metadata.dependencies + }} + + {:error, _} = error -> + Runtime.release_context(context, runtime) + {:reply, error, state} + end + else + {:error, _} = error -> {:reply, error, state} + end + end + + defp effective_sources(metadata, configured, base) do + automatic = + case metadata.root do + :none -> [] + :automatic -> if configured == [], do: [%{base: base, pattern: "**/*"}], else: [] + source -> [source] + end + + Enum.uniq(automatic ++ configured ++ metadata.sources) + end + + defp build_scanner([]), do: nil + defp build_scanner(sources), do: Oxide.new(sources: Enum.map(sources, &source/1)) + defp scan(nil), do: [] + defp scan(scanner), do: Oxide.scan(scanner) + + defp source(%{base: base, pattern: pattern} = source) do + %Oxide.Source{ + base: Path.expand(base), + pattern: pattern, + negated: Map.get(source, :negated, false) + } + end + + defp changed_file(path) when is_binary(path) do + %Oxide.Changed{file: path, extension: path |> Path.extname() |> String.trim_leading(".")} + end + + defp changed_file(map), do: struct!(Oxide.Changed, map) + + @impl true + def terminate(_reason, %{context: nil}), do: :ok + + def terminate(_reason, state) do + runtime = state.runtime || Volt.Tailwind.Runtime + if GenServer.whereis(runtime), do: Runtime.release_context(state.context, runtime) + :ok + catch + :exit, _reason -> :ok + end + + defp maybe_minify(css, false), do: css + + defp maybe_minify(css, true) do + case Vize.CSS.compile(css, minify: true) do + {:ok, %{code: minified}} -> minified + end + end +end diff --git a/lib/volt/test/discovery.ex b/lib/volt/test/discovery.ex index 0cb067b..7f54313 100644 --- a/lib/volt/test/discovery.ex +++ b/lib/volt/test/discovery.ex @@ -2,7 +2,7 @@ defmodule Volt.Test.Discovery do @moduledoc """ Discovers JavaScript and TypeScript test files for the Volt test runner. - Discovery is intentionally aligned with `Volt.JS.Helpers`: configured include + Discovery is intentionally aligned with `Volt.JS.Discovery`: configured include globs are expanded relative to `Volt.Test.Config.root`, exclude globs are expanded from the same root, non-files are ignored, and results are sorted for deterministic ExUnit generation. diff --git a/lib/volt/watcher.ex b/lib/volt/watcher.ex index aafdcf9..7fdae3a 100644 --- a/lib/volt/watcher.ex +++ b/lib/volt/watcher.ex @@ -23,6 +23,9 @@ defmodule Volt.Watcher do (default: common VCS, dependency, test-output, cache, and build directories) * `:tailwind` — enable Tailwind CSS rebuilds (default: `false`) * `:tailwind_css` — custom Tailwind input CSS (default: Tailwind base) + * `:tailwind_sources` — source globs scanned for Tailwind candidates + * `:tailwind_name` — logical Tailwind output name (default: input basename) + * `:tailwind_url` — browser URL broadcast for Tailwind style updates * `:target` — JS downlevel target * `:import_source` — JSX import source * `:vapor` — Vue Vapor mode @@ -44,14 +47,22 @@ defmodule Volt.Watcher do defstruct [ :root, :config, + :owner_monitor, + :managed_key, + :tables, + session: :default, fs_pids: [], pending: %{}, tailwind_timer: nil, + pending_reloads: [], tailwind_changed: [], tailwind_full?: false, tailwind_outdir: nil, + discovered_watches: %{}, + base_watch_dirs: [], tailwind_dirs: [], reload_dirs: [], + explicit_ignored: [], watch_ignored: [] ] @@ -64,24 +75,61 @@ defmodule Volt.Watcher do @impl true def init(opts) do + Process.flag(:trap_exit, true) + + owner_monitor = + case Keyword.get(opts, :owner) do + nil -> nil + owner when is_pid(owner) -> Process.monitor(owner) + end + root = Keyword.fetch!(opts, :root) |> Path.expand() watch_dirs = Keyword.get(opts, :watch_dirs, []) |> Enum.map(&Path.expand/1) reload_dirs = Keyword.get(opts, :reload_dirs, []) |> Enum.map(&Path.expand/1) tailwind_outdir = Keyword.get(opts, :tailwind_outdir) |> maybe_expand() + tailwind_name = Keyword.get(opts, :tailwind_name, "app") + tailwind_url = Keyword.get(opts, :tailwind_url, "/assets/css/#{tailwind_name}.css") + session = Keyword.get(opts, :session, :default) + tailwind_key = Keyword.get(opts, :tailwind_key, {:watcher, root}) + + tailwind_key = + if session == :default, do: tailwind_key, else: {:session, session, tailwind_key} + + configured_tailwind_sources = Keyword.get(opts, :tailwind_sources) config = opts |> Keyword.drop([ :root, :name, + :session, + :owner, + :managed_key, + :tables, :watch_dirs, :reload_dirs, :watch_ignored, + :tailwind_key, + :tailwind_name, + :tailwind_sources, + :tailwind_url, :tailwind_outdir ]) |> Map.new() - all_dirs = Enum.uniq([root | watch_dirs ++ reload_dirs ++ tailwind_colocated_dirs(config)]) + source_dirs = + if config[:tailwind] do + Enum.map(configured_tailwind_sources || [], &Path.expand(&1.base)) + else + [] + end + + all_dirs = + Enum.uniq([ + root | watch_dirs ++ reload_dirs ++ source_dirs ++ tailwind_colocated_dirs(config) + ]) + + tailwind_sources = configured_tailwind_sources || watcher_sources(all_dirs) watch_ignored = Volt.Watcher.Ignore.compile(Keyword.get(opts, :watch_ignored, []), all_dirs) fs_pids = @@ -91,25 +139,102 @@ defmodule Volt.Watcher do pid end) + config = + config + |> Map.put(:tailwind_key, tailwind_key) + |> Map.put(:tailwind_name, tailwind_name) + |> Map.put(:tailwind_configured_sources, tailwind_sources) + |> Map.put(:tailwind_sources, tailwind_sources) + |> Map.put(:tailwind_url, tailwind_url) + state = %__MODULE__{ root: root, + owner_monitor: owner_monitor, + tables: Keyword.get(opts, :tables), + managed_key: Keyword.get(opts, :managed_key), + session: Keyword.get(opts, :session, :default), fs_pids: fs_pids, config: config, tailwind_outdir: tailwind_outdir, + base_watch_dirs: all_dirs, tailwind_dirs: all_dirs, reload_dirs: reload_dirs, + explicit_ignored: + Volt.Watcher.Ignore.compile_explicit(Keyword.get(opts, :watch_ignored, []), all_dirs), watch_ignored: watch_ignored } if config[:tailwind] do - initial_tailwind_build(all_dirs, config[:tailwind_css], tailwind_outdir) + initial_tailwind_build( + tailwind_sources, + config[:tailwind_css], + tailwind_outdir, + tailwind_key, + tailwind_name, + config[:tailwind_runtime], + config[:tailwind_worker] + ) end - {:ok, state} + {:ok, refresh_tailwind_inputs(state)} + end + + defp refresh_tailwind_inputs(%{config: %{tailwind: true, tailwind_worker: worker}} = state) + when not is_nil(worker) do + inputs = Volt.Tailwind.Worker.inputs(worker) + + dirs = + Enum.uniq( + Enum.map(inputs.sources, &Path.expand(&1.base)) ++ + Enum.map(inputs.dependencies, &Path.dirname/1) + ) + + desired = + dirs + |> Enum.map(&existing_watch_parent/1) + |> Enum.uniq() + |> Enum.reject(&(&1 in state.base_watch_dirs)) + + {kept, removed} = Map.split(state.discovered_watches, desired) + + Enum.each(removed, fn {_dir, pid} -> + if Process.alive?(pid), do: GenServer.stop(pid) + end) + + added = + Map.new(desired -- Map.keys(kept), fn dir -> + {:ok, pid} = FileSystem.start_link(dirs: [dir]) + FileSystem.subscribe(pid) + {dir, pid} + end) + + watches = Map.merge(kept, added) + + config = + state.config + |> Map.put(:tailwind_sources, inputs.sources) + |> Map.put(:tailwind_dependencies, inputs.dependencies) + + %{ + state + | config: config, + discovered_watches: watches, + fs_pids: (state.fs_pids -- Map.values(removed)) ++ Map.values(added), + tailwind_dirs: state.base_watch_dirs ++ Map.keys(watches) + } end - defp initial_tailwind_build(dirs, css_path, outdir) do - case build_tailwind(dirs, css_path, outdir) do + defp refresh_tailwind_inputs(state), do: state + + defp existing_watch_parent(path) do + parent = Path.dirname(path) + if File.dir?(path) or parent == path, do: path, else: existing_watch_parent(parent) + end + + 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 {:ok, css} -> Logger.debug("[Volt] Initial Tailwind build: #{byte_size(css)} bytes") @@ -118,16 +243,20 @@ defmodule Volt.Watcher do end end - defp build_tailwind(dirs, css_path, outdir) do - sources = Enum.map(dirs, &%{base: &1, pattern: "**/*"}) + defp build_tailwind(sources, css_path, outdir, key, name, runtime, worker) do + worker_opts = if worker, do: [worker: worker], else: [key: key, runtime: runtime] with {:ok, {css_input, css_base}} <- read_tailwind_css(css_path), - {:ok, css} <- Volt.Tailwind.build(sources: sources, css: css_input, css_base: css_base) do - if outdir do - File.mkdir_p!(outdir) - File.write!(Path.join(outdir, "app.css"), css) - end - + {:ok, css} <- + Volt.Tailwind.build( + worker_opts ++ + [ + sources: sources, + css: css_input, + css_base: css_base + ] + ) do + Volt.Tailwind.Artifact.write(outdir, name, css) {:ok, css} end end @@ -152,6 +281,13 @@ defmodule Volt.Watcher do tailwind_output?(path, state) -> {:noreply, state} + path in Map.get(state.config, :tailwind_dependencies, []) -> + {:noreply, maybe_schedule_tailwind(state, path, full?: true)} + + reload_path?(path, state) and state.config[:tailwind] -> + state = %{state | pending_reloads: Enum.uniq([path | state.pending_reloads])} + {:noreply, maybe_schedule_tailwind(state, path)} + ext in Extensions.css() -> state = handle_css_change(path, state) {:noreply, state} @@ -170,8 +306,11 @@ defmodule Volt.Watcher do {:noreply, state} reload_path?(path, state) -> - handle_reload_change(path) - {:noreply, state} + handle_reload_change(path, state) + {:noreply, maybe_schedule_tailwind(state, path)} + + tailwind_source?(path, state) -> + {:noreply, maybe_schedule_tailwind(state, path)} true -> {:noreply, state} @@ -191,14 +330,32 @@ defmodule Volt.Watcher do changed = state.tailwind_changed full? = state.tailwind_full? state = %{state | tailwind_timer: nil, tailwind_changed: [], tailwind_full?: false} - handle_tailwind_rebuild(changed, full?, state) - {:noreply, state} + + case handle_tailwind_rebuild(changed, full?, state) do + :ok -> + Enum.each(state.pending_reloads, &handle_reload_change(&1, state)) + {:noreply, refresh_tailwind_inputs(%{state | pending_reloads: []})} + + {:error, _reason} -> + {:noreply, refresh_tailwind_inputs(%{state | tailwind_full?: true})} + end end def handle_info({:file_event, _pid, :stop}, state) do {:noreply, state} end + def handle_info({:DOWN, ref, :process, _pid, _reason}, %{owner_monitor: ref} = state) + when is_reference(ref) do + {:stop, :normal, state} + end + + def handle_info({:EXIT, pid, reason}, state) do + if pid in state.fs_pids, + do: {:stop, {:filesystem_watcher_exit, reason}, state}, + else: {:noreply, state} + end + def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do {:noreply, state} end @@ -208,7 +365,12 @@ defmodule Volt.Watcher do end defp ignored_path?(path, state) do - Enum.any?(state.watch_ignored, &GlobEx.match?(&1, path)) + patterns = + if path in Map.get(state.config, :tailwind_dependencies, []), + do: state.explicit_ignored, + else: state.watch_ignored + + Enum.any?(patterns, &GlobEx.match?(&1, path)) end defp schedule_rebuild(state, path) do @@ -240,40 +402,57 @@ defmodule Volt.Watcher do defp handle_js_change(path, state) do relative = Path.relative_to(path, state.root) css? = css_file?(path) - css_dependents = if css?, do: Volt.HMR.StyleGraph.dependents(path), else: [] - old_entry = Volt.Cache.get_file(path) - Volt.Cache.evict_file(path) - Volt.HMR.ModuleGraph.invalidate_file(path) + css_dependents = + if css?, do: Volt.HMR.StyleGraph.dependents(path, state.tables || state.session), else: [] + + old_entry = Volt.Cache.get_file(path, state.tables || state.session) + Volt.Cache.evict_file(path, state.tables || state.session) + + Volt.HMR.ModuleGraph.invalidate_file( + path, + System.system_time(:millisecond), + state.tables || state.session + ) case File.read(path) do {:ok, source} -> case Volt.Pipeline.compile(path, source, Map.to_list(state.config)) do {:ok, result} -> - Volt.HMR.GlobGraph.update_from_source(path, source) - Volt.HMR.ImportGraph.update_from_compiled(path, result.code) + Volt.HMR.GlobGraph.update_from_source(path, source, state.tables || state.session) + + Volt.HMR.ImportGraph.update_from_compiled( + path, + result.code, + state.tables || state.session + ) - Volt.HMR.StyleDependencies.update_from_compile(path, source, result) + Volt.HMR.StyleDependencies.update_from_compile( + path, + source, + result, + state.tables || state.session + ) changes = if css?, do: [:style], else: detect_changes(old_entry, result) - broadcast_change(path, relative, changes, state.root) - broadcast_css_dependents(css_dependents, state.root) - broadcast_glob_dependents(path, state.root) + 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}) + HMR.broadcast(:error, %{path: relative, reason: reason}, session: state.session) end {:error, reason} when reason in [:enoent, :eacces, :eperm] -> - Volt.HMR.ImportGraph.remove(path) - Volt.HMR.GlobGraph.remove(path) - if css?, do: Volt.HMR.StyleGraph.remove(path) - Volt.HMR.ModuleGraph.remove_file(path) - HMR.update(relative, [:full]) - broadcast_glob_dependents(path, state.root) + Volt.HMR.ImportGraph.remove(path, state.tables || state.session) + 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.update(relative, [:full], session: state.session) + broadcast_glob_dependents(path, state) {:error, reason} -> - HMR.broadcast(:error, %{path: relative, reason: inspect(reason)}) + HMR.broadcast(:error, %{path: relative, reason: inspect(reason)}, session: state.session) end end @@ -281,23 +460,33 @@ defmodule Volt.Watcher do defp handle_css_change(path, state) do relative = Path.relative_to(path, state.root) - css_dependents = StyleGraph.dependents(path) + css_dependents = StyleGraph.dependents(path, state.tables || state.session) tailwind_input? = tailwind_input?(path, state) - Volt.Cache.evict_file(path) - Volt.HMR.ModuleGraph.invalidate_file(path) + Volt.Cache.evict_file(path, state.tables || state.session) + + Volt.HMR.ModuleGraph.invalidate_file( + path, + System.system_time(:millisecond), + state.tables || state.session + ) if File.regular?(path) do source = File.read!(path) - StyleGraph.update(path, Volt.CSS.Dependencies.resolve(source, path)) + + StyleGraph.update( + path, + Volt.CSS.Dependencies.resolve(source, path), + state.tables || state.session + ) else - StyleGraph.remove(path) + StyleGraph.remove(path, state.tables || state.session) end if Volt.Path.inside?(path, state.root) and not tailwind_input? do - HMR.broadcast(:update, %{path: relative, changes: [:style]}) - broadcast_css_dependents(css_dependents, state.root) - broadcast_glob_dependents(path, state.root) + HMR.broadcast(:update, %{path: relative, changes: [:style]}, session: state.session) + broadcast_css_dependents(css_dependents, state) + broadcast_glob_dependents(path, state) end maybe_schedule_tailwind(state, path, full?: true) @@ -313,48 +502,60 @@ defmodule Volt.Watcher do defp tailwind_output?(_path, %{tailwind_outdir: nil}), do: false defp tailwind_output?(path, state), do: Volt.Path.inside?(path, state.tailwind_outdir) - defp broadcast_css_dependents(dependents, root) do + defp broadcast_css_dependents(dependents, state) do dependents |> Enum.uniq() |> Enum.each(fn importer -> - Volt.Cache.evict_file(importer) - Volt.HMR.ModuleGraph.invalidate_file(importer) - relative = Path.relative_to(importer, root) - HMR.broadcast(:update, %{path: relative, changes: [:style]}) + Volt.Cache.evict_file(importer, state.tables || state.session) + + Volt.HMR.ModuleGraph.invalidate_file( + importer, + System.system_time(:millisecond), + state.session + ) + + relative = Path.relative_to(importer, state.root) + HMR.broadcast(:update, %{path: relative, changes: [:style]}, session: state.session) end) end - defp handle_reload_change(path) do + defp handle_reload_change(path, state) do path = Path.relative_to_cwd(path) - HMR.full_reload(path) + HMR.full_reload(path, session: state.session) end defp handle_asset_change(path, state) do relative = Path.relative_to(path, state.root) - css_dependents = Volt.HMR.StyleGraph.dependents(path) + css_dependents = Volt.HMR.StyleGraph.dependents(path, state.tables || state.session) + + Volt.Cache.evict_file(path, state.tables || state.session) - Volt.Cache.evict_file(path) - Volt.HMR.ModuleGraph.invalidate_file(path) - HMR.broadcast(:update, %{path: relative, changes: [:full]}) - broadcast_css_dependents(css_dependents, state.root) - broadcast_glob_dependents(path, state.root) + Volt.HMR.ModuleGraph.invalidate_file( + path, + System.system_time(:millisecond), + state.tables || state.session + ) + + HMR.broadcast(:update, %{path: relative, changes: [:full]}, session: state.session) + broadcast_css_dependents(css_dependents, state) + broadcast_glob_dependents(path, state) end - defp broadcast_glob_dependents(path, root) do + defp broadcast_glob_dependents(path, state) do path - |> Volt.HMR.GlobGraph.dependents() + |> Volt.HMR.GlobGraph.dependents(state.tables || state.session) |> Enum.reject(&(&1 == path)) |> Enum.each(fn importer -> - Volt.Cache.evict_file(importer) - relative = Path.relative_to(importer, root) - HMR.broadcast(:update, %{path: relative, changes: [:full]}) + Volt.Cache.evict_file(importer, state.tables || state.session) + relative = Path.relative_to(importer, state.root) + HMR.broadcast(:update, %{path: relative, changes: [:full]}, session: state.session) end) end - defp broadcast_change(path, relative, changes, root) do + defp broadcast_change(path, relative, changes, state) do cond do changes == [:style] -> - HMR.broadcast(:update, %{path: relative, changes: [:style]}) + HMR.broadcast(:update, %{path: relative, changes: [:style]}, session: state.session) changes == [] -> :ok @@ -367,32 +568,52 @@ defmodule Volt.Watcher do end end - case Volt.HMR.Boundary.find_boundary(path, read_source) do + case Volt.HMR.Boundary.find_boundary(path, read_source, state.tables || state.session) do {:ok, boundary_path} -> timestamp = System.system_time(:millisecond) - boundary_relative = Path.relative_to(boundary_path, root) - - HMR.broadcast(:update, %{ - path: relative, - changes: [:hmr], - boundary: boundary_relative, - timestamp: timestamp - }) + boundary_relative = Path.relative_to(boundary_path, state.root) + + HMR.broadcast( + :update, + %{ + path: relative, + changes: [:hmr], + boundary: boundary_relative, + timestamp: timestamp + }, + session: state.session + ) :full_reload -> - HMR.broadcast(:update, %{path: relative, changes: changes}) + HMR.broadcast(:update, %{path: relative, changes: changes}, session: state.session) end end end defp handle_tailwind_rebuild(_changed_paths, true, state) do - case build_tailwind(state.tailwind_dirs, state.config[:tailwind_css], state.tailwind_outdir) do + previous = previous_stylesheet(state) + + case build_tailwind( + state.config.tailwind_configured_sources, + state.config[:tailwind_css], + state.tailwind_outdir, + state.config.tailwind_key, + state.config.tailwind_name, + state.config[:tailwind_runtime], + state.config[:tailwind_worker] + ) do {:ok, css} -> - HMR.broadcast(:update, %{path: "assets/css/app.css", changes: [:style]}) + if previous != {:ok, css} do + HMR.broadcast(:update, %{path: state.config.tailwind_url, changes: [:style]}, + session: state.session + ) + end + Logger.debug("[Volt] Tailwind rebuilt (#{byte_size(css)} bytes)") + :ok {:error, reason} -> - HMR.broadcast(:error, %{path: "tailwind", reason: inspect(reason)}) + tailwind_error(reason, state.session) end end @@ -403,24 +624,55 @@ defmodule Volt.Watcher do %{file: path, extension: ext} end) - with {:ok, {css_input, css_base}} <- read_tailwind_css(state.config[:tailwind_css]), - {:ok, css} <- Volt.Tailwind.rebuild(changed, css: css_input, css_base: css_base) do - if outdir = state.tailwind_outdir do - File.mkdir_p!(outdir) - File.write!(Path.join(outdir, "app.css"), css) + worker_opts = + case state.config[:tailwind_worker] do + nil -> [key: state.config.tailwind_key] + worker -> [worker: worker] end - HMR.broadcast(:update, %{path: "assets/css/app.css", changes: [:style]}) + with {:ok, {css_input, css_base}} <- read_tailwind_css(state.config[:tailwind_css]), + {:ok, css} <- + Volt.Tailwind.rebuild( + changed, + worker_opts ++ + [ + css: css_input, + css_base: css_base + ] + ) do + Volt.Tailwind.Artifact.write(state.tailwind_outdir, state.config.tailwind_name, css) + + HMR.broadcast(:update, %{path: state.config.tailwind_url, changes: [:style]}, + session: state.session + ) + Logger.debug("[Volt] Tailwind rebuilt (#{byte_size(css)} bytes)") + :ok else :unchanged -> :ok {:error, reason} -> - HMR.broadcast(:error, %{path: "tailwind", reason: inspect(reason)}) + tailwind_error(reason, state.session) end end + defp previous_stylesheet(state) do + worker = + state.config[:tailwind_worker] || + case Registry.lookup(Volt.Tailwind.Registry, state.config.tailwind_key) do + [{pid, _}] -> pid + [] -> nil + end + + 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) + {:error, reason} + end + defp detect_changes(nil, _new), do: [:full] defp detect_changes(old_entry, new_result) do @@ -437,6 +689,17 @@ defmodule Volt.Watcher do end end + defp tailwind_source?(path, state) do + state.config[:tailwind] && + Enum.any?(state.config.tailwind_sources, fn source -> + not Map.get(source, :negated, false) and + GlobEx.match?( + GlobEx.compile!(Path.join(Path.expand(source.base), source.pattern)), + path + ) + end) + end + defp reload_path?(path, state) do Enum.any?(state.reload_dirs, &Volt.Path.inside?(path, &1)) end @@ -455,7 +718,15 @@ defmodule Volt.Watcher do @impl true def terminate(_reason, state) do - Enum.each(state.fs_pids, &GenServer.stop/1) + Enum.each(state.fs_pids, fn pid -> + if Process.alive?(pid), do: GenServer.stop(pid) + end) + + if state.config[:tailwind] && is_nil(state.config[:tailwind_worker]), + do: Volt.Tailwind.Supervisor.release(state.config.tailwind_key) + + if state.session != :default, do: Volt.Dev.Cleanup.run(state.session) + if state.managed_key, do: Registry.unregister(Volt.Dev.WatcherRegistry, state.managed_key) :ok end diff --git a/lib/volt/watcher/ignore.ex b/lib/volt/watcher/ignore.ex index 717ab53..59a3ad1 100644 --- a/lib/volt/watcher/ignore.ex +++ b/lib/volt/watcher/ignore.ex @@ -10,8 +10,12 @@ defmodule Volt.Watcher.Ignore do ] @spec compile([String.t()], [String.t()]) :: [GlobEx.t()] - def compile(patterns, roots) do - (@default_patterns ++ patterns) + def compile(patterns, roots), do: compile_patterns(@default_patterns ++ patterns, roots) + + def compile_explicit(patterns, roots), do: compile_patterns(patterns, roots) + + defp compile_patterns(patterns, roots) do + patterns |> Enum.uniq() |> Enum.flat_map(&compile_pattern(&1, roots)) end diff --git a/lib/volt/watcher/path.ex b/lib/volt/watcher/path.ex index 0fd9225..b95cd14 100644 --- a/lib/volt/watcher/path.ex +++ b/lib/volt/watcher/path.ex @@ -29,7 +29,7 @@ defmodule Volt.Watcher.Path do case File.stat(path) do {:ok, path_stat} -> if same_file?(path_stat, root_stat) do - {:ok, Path.join(parts)} + {:ok, join_parts(parts)} else continue_to_parent(path, root_stat, parts) end @@ -49,6 +49,9 @@ defmodule Volt.Watcher.Path do end end + defp join_parts([]), do: "." + defp join_parts(parts), do: Path.join(parts) + defp same_file?(left, right) do left.major_device == right.major_device and left.minor_device == right.minor_device and left.inode == right.inode diff --git a/mix.exs b/mix.exs index 0029516..16f5d12 100644 --- a/mix.exs +++ b/mix.exs @@ -12,7 +12,7 @@ defmodule Volt.MixProject do start_permanent: Mix.env() == :prod, deps: deps(), aliases: aliases(), - test_ignore_filters: [&String.starts_with?(&1, "test/support/")], + elixirc_paths: elixirc_paths(Mix.env()), dialyzer: [plt_add_apps: [:mix, :ex_unit], flags: [:no_opaque]], name: "Volt", description: @@ -31,6 +31,9 @@ defmodule Volt.MixProject do ] end + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_env), do: ["lib"] + defp deps do [ {:reach, "~> 2.6.1", only: [:dev, :test], runtime: false}, @@ -38,7 +41,7 @@ defmodule Volt.MixProject do {:oxc, "~> 0.17.8"}, {:vize, "~> 0.14.2"}, {:oxide_ex, "~> 0.2.2"}, - {:quickbeam, "~> 0.11.0"}, + {:quickbeam, "~> 0.11.1"}, {:dotenvy, "~> 1.1"}, {:floki, "~> 0.38"}, {:plug, "~> 1.16"}, @@ -47,7 +50,7 @@ defmodule Volt.MixProject do {:file_system, "~> 1.0"}, {:jason, "~> 1.4"}, {:json_codec, "~> 0.2.3"}, - {:igniter, "~> 0.5", optional: true}, + {:igniter, ">= 0.8.4 and < 1.0.0", optional: true}, {:npm, "~> 0.7.6"}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, @@ -55,7 +58,7 @@ defmodule Volt.MixProject do {:ex_dna, "~> 1.1", only: [:dev, :test], runtime: false}, {:ex_doc, "~> 0.35", only: :dev, runtime: false}, {:makeup_js, "~> 0.1", only: :dev, runtime: false}, - {:bandit, "~> 1.0", only: :test}, + {:bandit, ">= 1.12.5 and < 2.0.0", only: :test}, {:playwright_ex, "~> 0.5", only: :test} ] end @@ -218,7 +221,7 @@ defmodule Volt.MixProject do Volt.JS.Asset, Volt.JS.AST, Volt.JS.Extensions, - Volt.JS.Helpers, + Volt.JS.Discovery, Volt.JS.ImportExtractor, Volt.JS.ImportExtractor.Result, Volt.JS.Package, diff --git a/mix.lock b/mix.lock index affe00f..eebb01e 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,5 @@ %{ - "bandit": {:hex, :bandit, "1.12.4", "10bbab488edf8162318d736c19c5837077b8fca2bf5d95b07b33830387124f62", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "84513318c5752a2a8017664450f889b47fae5d53d64698ddf1e4fb09a7449e8d"}, + "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.21", "0a0e8330dc267a40a3b7ad86d39302764bb71758172904e6a59d5ad6443ce307", [:mix], [], "hexpm", "e42e22723e25dbd46876d056a03f685513d6e98f6b5e555dc551321decd76c5c"}, "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, @@ -12,7 +12,7 @@ "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"}, "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"}, "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, - "ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"}, + "ex_ast": {:hex, :ex_ast, "0.12.10", "1126eb2afe3218e7a8a53ce85adf1056c528c88fbb5adcbe0191b647b842b377", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "e03f668c4354e3a1382c3d762c0fcc82ca9670f6f37e62b9097ce752be6adf39"}, "ex_dna": {:hex, :ex_dna, "1.5.3", "c4c4736c1e22538002844f194fbd2b942f3a0c2a3517c843306b219f27bc5525", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: true]}, {:gen_lsp, "~> 0.11", [hex: :gen_lsp, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: true]}], "hexpm", "28196153994e99aae29a955ee9f1d37ea6d3a33dbac0896ac4b937ebc622c313"}, "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"}, @@ -20,9 +20,9 @@ "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"}, "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.2.3", "0d2ee20fbceb251d573f03ef34852e325529c2874ab66d1b576384021318996c", [:mix], [], "hexpm", "9daeae2ea6b8ad3dc7f51a10484f6bc1d0f705c31063383830a7167ab93b887b"}, + "hex_solver": {:hex, :hex_solver, "0.3.0", "81e7659ad6caba1f856d89fc7ca52f88e8eb4f801deedcdce215f9006d706d1d", [:mix], [], "hexpm", "8a04c8ef0df25ca1f5e4d7d5f32833fac569f5c6442f05e216ee33615593e64d"}, "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, - "igniter": {:hex, :igniter, "0.8.1", "3c6ea47f3a6031015e29da8b4ba5c685f0a2e409facf63041fd83e982ca3aa89", [: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", "d99472e6daf3bfc3675d699c6c7ace9196f377207aab83e09d7b95e9d90e8ae8"}, + "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"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, @@ -31,7 +31,7 @@ "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.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [: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", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"}, + "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_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"}, @@ -49,14 +49,14 @@ "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.0", "2e34ec4128452d6a8e0b6e3d3ab673c748c5f78ff67e3d86f43aac96f63179d7", [:mix], [{:jason, "~> 1.4", [hex: :jason, 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.5", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "5914d43b5129005f0217f6252e97b48306dc9ed02dec0bfebdb94f8c9a98148f"}, + "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"}, "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.2", "364eae2e5f5c984f2dac6d71c07f8c8c89ce0bc49c4d746dacb7a306823020de", [: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", "c9cdfa276b05d8db2a27fda5d233e6858b764d47189d76cbb186e130a871ae0b"}, + "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"}, "sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"}, - "spitfire": {:hex, :spitfire, "0.3.13", "edd207b065eaec57acc5484097d0aa3e97fe4246168c54e67a9af040b8dee4c1", [:mix], [], "hexpm", "3601be88ceed4967b584e96444de3e1d12d6555ae0864a7390b9cd5332d134b4"}, + "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"}, @@ -67,5 +67,5 @@ "zig_get": {:hex, :zig_get, "0.15.2", "a6ccaa894213839ba95615bf9be2b2c9268e37ab9547a2344830202cfd6d7cc0", [:mix], [], "hexpm", "e6b0028f2d5a8da791ff8037deff5b492784017d8d241e598377a24bd765f56f"}, "zig_parser": {:hex, :zig_parser, "0.6.0", "b1296c64bf5c2592de2eb4bc8bbf79d85b1d6a3ab444c443becd7af579d85681", [:mix], [{:pegasus, "~> 0.2.4", [hex: :pegasus, repo: "hexpm", optional: false]}], "hexpm", "bb7a1523b69f7f8f6b74ba5bf9dabc83f512c0cd67d9eebba1c501a529a2f95e"}, "zigler": {:hex, :zigler, "0.15.2", "d3e8c7b6d88be2eea72c341376b7e7b0aa36248e26d5798b580cad9815311559", [:mix], [{:protoss, "~> 1.0", [hex: :protoss, repo: "hexpm", optional: false]}, {:zig_get, "0.15.2", [hex: :zig_get, repo: "hexpm", optional: false]}, {:zig_parser, "~> 0.6", [hex: :zig_parser, repo: "hexpm", optional: false]}], "hexpm", "6bf96df41e281a70147e8826e439e24945f0222e08d058fc544da84f5c7ea8dd"}, - "zigler_precompiled": {:hex, :zigler_precompiled, "0.1.5", "7da17a36e168a69c06e32e8f0dcf739e8c09450dcfee65aad38a025c20fc2a9a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:zigler, "~> 0.13", [hex: :zigler, repo: "hexpm", optional: true]}], "hexpm", "4ee01cfdcd703215cf331f1ecc4324874ee2e3bfe15e7b2f3d65231c551a0571"}, + "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/priv/ts/compilers/tailwind.ts b/priv/ts/compilers/tailwind.ts index dad2af4..a1f1570 100644 --- a/priv/ts/compilers/tailwind.ts +++ b/priv/ts/compilers/tailwind.ts @@ -2,23 +2,33 @@ type BeamModuleSpec = { path: string base: string code: string + dependencies: string[] format: 'cjs' | 'json' } +type Source = { base: string; pattern: string; negated: boolean } + type TailwindCompiler = { compile: ( css: string, options: { base: string from: string - loadStylesheet: (id: string, base?: string) => Promise<{ base: string; content: string }> + loadStylesheet: ( + id: string, + base?: string + ) => Promise<{ path: string; base: string; content: string }> loadModule: ( id: string, base?: string, type?: string - ) => Promise<{ module: unknown; base: string }> + ) => Promise<{ path: string; module: unknown; base: string }> } - ) => Promise<{ build: (candidates: string[]) => string }> + ) => Promise<{ + build: (candidates: string[]) => string + root: null | 'none' | { base: string; pattern: string } + sources: Source[] + }> Features?: unknown } @@ -66,62 +76,131 @@ const tailwindRuntimeSpec = Beam.callSync( ) as BeamModuleSpec const tailwindExports = requireResolvedModule(tailwindRuntimeSpec, new Map()) as TailwindCompiler +const compilerContexts = new Map>>() +let nextCompilerContext = 0 + async function compileTailwindCss( inputCss: string | null, candidates: string[] | null, - base: string | null + base: string | null, + scan: { base: string; sources: Source[] } | null = null, + metadata = false, + retain = false ) { + const dependencies = new Set() const moduleCache = new Map() const rootBase = normalizeBase(base, TAILWIND_DEFAULT_BASE) const css = inputCss === null ? '@import "tailwindcss";' : inputCss - const compiler = await tailwindExports.compile(css, { - base: rootBase, - from: 'app.css', - loadStylesheet: async (id, currentBase) => { - if (id === 'tailwindcss') { - return { - base: rootBase, - content: - '@import "tailwindcss/theme.css" layer(theme);\n@import "tailwindcss/preflight.css" layer(base);\n@import "tailwindcss/utilities.css" layer(utilities);' + let compiler: Awaited> + try { + compiler = await tailwindExports.compile(css, { + base: rootBase, + from: 'app.css', + loadStylesheet: async (id, currentBase) => { + if (id === 'tailwindcss') { + return { + path: path.join(TAILWIND_ROOT, 'index.css'), + base: rootBase, + content: + '@import "tailwindcss/theme.css" layer(theme);\n@import "tailwindcss/preflight.css" layer(base);\n@import "tailwindcss/utilities.css" layer(utilities);' + } } - } - if (id === 'tailwindcss/theme.css') { - return { - base: rootBase, - content: tailwindExports.Features ? fs.readFileSync(themeCssPath, 'utf8') : '' + if (id === 'tailwindcss/theme.css') { + return { + path: themeCssPath, + base: rootBase, + content: tailwindExports.Features ? fs.readFileSync(themeCssPath, 'utf8') : '' + } } - } - if (id === 'tailwindcss/preflight.css') { - return { base: rootBase, content: fs.readFileSync(preflightCssPath, 'utf8') } - } + if (id === 'tailwindcss/preflight.css') { + return { + path: preflightCssPath, + base: rootBase, + content: fs.readFileSync(preflightCssPath, 'utf8') + } + } + + if (id === 'tailwindcss/utilities.css') { + return { + path: utilitiesCssPath, + base: rootBase, + content: fs.readFileSync(utilitiesCssPath, 'utf8') + } + } - if (id === 'tailwindcss/utilities.css') { - return { base: rootBase, content: fs.readFileSync(utilitiesCssPath, 'utf8') } + const stylesheet = Beam.callSync( + 'tailwind.load_stylesheet', + id, + normalizeBase(currentBase, rootBase), + rootBase + ) as + | { path: string; base: string; content: string } + | { error: string; candidates: string[] } + if ('error' in stylesheet) { + for (const candidate of stylesheet.candidates) dependencies.add(candidate) + throw new Error(stylesheet.error) + } + dependencies.add(stylesheet.path) + return stylesheet + }, + loadModule: async (id, currentBase, type) => { + const spec = Beam.callSync( + 'tailwind.load_module', + id, + normalizeBase(currentBase, rootBase), + type ?? 'plugin' + ) as BeamModuleSpec | { error: string; candidates: string[] } + + if ('error' in spec) { + for (const candidate of spec.candidates) dependencies.add(candidate) + throw new Error(spec.error) + } + for (const dependency of spec.dependencies) dependencies.add(dependency) + const mod = requireResolvedModule(spec, moduleCache) + return { path: spec.path, module: unwrapModule(mod), base: spec.base } } + }) + } catch (error) { + if (retain) return { error: String(error), dependencies: [...dependencies].sort() } + throw error + } - return Beam.callSync( - 'tailwind.load_stylesheet', - id, - normalizeBase(currentBase, rootBase) - ) as { base: string; content: string } - }, - loadModule: async (id, currentBase, type) => { - const spec = Beam.callSync( - 'tailwind.load_module', - id, - normalizeBase(currentBase, rootBase), - type ?? 'plugin' - ) as BeamModuleSpec - - const mod = requireResolvedModule(spec, moduleCache) - return { module: unwrapModule(mod), base: spec.base } + if (scan) { + let automatic: Source[] = [] + if (compiler.root === null) { + automatic = [{ base: scan.base, pattern: '**/*', negated: false }] + } else if (compiler.root !== 'none') { + automatic = [{ ...compiler.root, negated: false }] } - }) - - return compiler.build(candidates ?? []) + candidates = Beam.callSync('tailwind.scan', [ + ...automatic, + ...scan.sources, + ...compiler.sources + ]) as string[] + } + if (retain) { + const id = ++nextCompilerContext + compilerContexts.set(id, compiler) + return { + id, + code: '', + dependencies: [...dependencies].sort(), + sources: compiler.sources, + root: compiler.root + } + } + const code = compiler.build(candidates ?? []) + return metadata + ? { + code, + dependencies: [...dependencies].sort(), + sources: compiler.sources, + root: compiler.root + } + : code } function normalizeBase(base: string | null | undefined, fallbackBase: string) { @@ -197,4 +276,20 @@ function loadCommonJSModule( } } +async function prepareTailwindContext(css: string | null, base: string) { + return compileTailwindCss(css, [], base, null, true, true) +} +function buildTailwindContext(id: number, candidates: string[]) { + const compiler = compilerContexts.get(id) + if (!compiler) throw new Error('Unknown Tailwind compiler context') + return compiler.build(candidates) +} +function releaseTailwindContext(id: number) { + return compilerContexts.delete(id) +} + +globalThis.prepareTailwindContext = prepareTailwindContext +globalThis.buildTailwindContext = buildTailwindContext +globalThis.releaseTailwindContext = releaseTailwindContext + globalThis.compileTailwindCss = compileTailwindCss diff --git a/test/mix/tasks/volt.build_test.exs b/test/mix/tasks/volt/build_test.exs similarity index 56% rename from test/mix/tasks/volt.build_test.exs rename to test/mix/tasks/volt/build_test.exs index 741b083..bae2688 100644 --- a/test/mix/tasks/volt.build_test.exs +++ b/test/mix/tasks/volt/build_test.exs @@ -20,6 +20,87 @@ defmodule Mix.Tasks.Volt.BuildTest do {:ok, tmp_dir: tmp_dir} end + test "explicit Tailwind enablement works without configured roots", %{tmp_dir: root} do + original = Application.get_env(:volt, :tailwind) + Application.put_env(:volt, :tailwind, []) + + on_exit(fn -> + if is_nil(original), + do: Application.delete_env(:volt, :tailwind), + else: Application.put_env(:volt, :tailwind, original) + end) + + entry = Path.join(root, "src/app.js") + File.write!(entry, "console.log('ready')") + css = Path.join(root, "src/custom.css") + File.write!(css, "@import 'tailwindcss' source(none); .custom { color: red }") + + for {name, flags, expected} <- [ + {"default", ["--tailwind"], "app.css"}, + {"custom", ["--tailwind-css", css], "custom.css"}, + {"disabled", ["--tailwind-css", css, "--no-tailwind"], nil} + ] do + outdir = Path.join(root, name) + + try do + Mix.Tasks.Volt.Build.run( + ["--entry", entry, "--outdir", outdir, "--no-hash", "--no-minify"] ++ flags + ) + catch + :exit, reason -> + receive do + {:mix_shell, :error, [message]} -> flunk("#{name}: #{message}") + after + 0 -> exit(reason) + end + end + + styles = Path.wildcard(Path.join(outdir, "css/*.css")) + assert Enum.map(styles, &Path.basename/1) == List.wrap(expected) + end + end + + test "nested flat assets preserve CDN URLs and sourcemaps", %{tmp_dir: root} do + entry = Path.join(root, "src/app.js") + File.write!(Path.join(root, "src/logo.svg"), "") + File.write!(Path.join(root, "src/style.css"), ".logo { background: url('./logo.svg') }") + + File.write!( + entry, + "import logo from './logo.svg?url'; import './style.css'; console.log(logo)" + ) + + outdir = Path.join(root, "dist") + + Mix.Tasks.Volt.Build.run([ + "--entry", + entry, + "--outdir", + outdir, + "--assets-dir", + "assets", + "--output-layout", + "flat", + "--no-hash", + "--no-minify", + "--no-tailwind", + "--asset-url-prefix", + "https://cdn.example/site", + "--sourcemap", + "true" + ]) + + manifest = outdir |> Path.join("manifest.json") |> File.read!() |> Jason.decode!() + js = File.read!(Path.join(outdir, manifest["app.js"]["file"])) + css = File.read!(Path.join(outdir, manifest["app.css"]["file"])) + assert [image] = Path.wildcard(Path.join(outdir, "assets/logo-*.svg")) + url = "https://cdn.example/site/assets/" <> Path.basename(image) + assert js =~ url + assert css =~ url + assert js =~ "sourceMappingURL=app.js.map" + assert File.regular?(Path.join(outdir, "assets/app.js.map")) + end + test "--no-tree-shaking is accepted", %{tmp_dir: tmp_dir} do entry = Path.join(tmp_dir, "src/app.js") outdir = Path.join(tmp_dir, "dist") @@ -81,7 +162,7 @@ defmodule Mix.Tasks.Volt.BuildTest do ]) js_path = Path.join([outdir, "js", "app.js"]) - assert File.read!(js_path) =~ ~r(/cdn/assets/logo-[a-f0-9]{8}\.svg) + assert File.read!(js_path) =~ ~r(/cdn/assets/js/logo-[a-f0-9]{8}\.svg) end test "--sourcemap false disables production sourcemaps", %{tmp_dir: tmp_dir} do @@ -142,11 +223,10 @@ defmodule Mix.Tasks.Volt.BuildTest do Mix.Tasks.Volt.Build.run(["--tailwind"]) - css_manifest = outdir |> Path.join("css/manifest.json") |> File.read!() |> Jason.decode!() + manifest = outdir |> Path.join("manifest.json") |> File.read!() |> Jason.decode!() - assert File.regular?(Path.join([outdir, "css", "app.css"])) - assert File.regular?(Path.join([outdir, "js", "app.js"])) - assert css_manifest["app.css"]["file"] == "app.css" + assert manifest["app.css"]["file"] == "css/app.css" + assert manifest["app.js"]["file"] == "js/app.js" refute outdir |> Path.join("css") diff --git a/test/mix/tasks/volt.install_test.exs b/test/mix/tasks/volt/install_test.exs similarity index 99% rename from test/mix/tasks/volt.install_test.exs rename to test/mix/tasks/volt/install_test.exs index 3960df2..edf3c53 100644 --- a/test/mix/tasks/volt.install_test.exs +++ b/test/mix/tasks/volt/install_test.exs @@ -1,4 +1,4 @@ -defmodule Volt.InstallTest do +defmodule Mix.Tasks.Volt.InstallTest do use ExUnit.Case alias Igniter.Test diff --git a/test/mix/tasks/volt.js.check_test.exs b/test/mix/tasks/volt/js/check_test.exs similarity index 99% rename from test/mix/tasks/volt.js.check_test.exs rename to test/mix/tasks/volt/js/check_test.exs index 350c405..b6f3bf1 100644 --- a/test/mix/tasks/volt.js.check_test.exs +++ b/test/mix/tasks/volt/js/check_test.exs @@ -1,4 +1,4 @@ -defmodule Volt.JsCheckTest do +defmodule Mix.Tasks.Volt.Js.CheckTest do use ExUnit.Case, async: false import ExUnit.CaptureIO diff --git a/test/mix/tasks/volt.lint_test.exs b/test/mix/tasks/volt/lint_test.exs similarity index 99% rename from test/mix/tasks/volt.lint_test.exs rename to test/mix/tasks/volt/lint_test.exs index b77e38a..c532867 100644 --- a/test/mix/tasks/volt.lint_test.exs +++ b/test/mix/tasks/volt/lint_test.exs @@ -1,4 +1,4 @@ -defmodule Volt.LintTest do +defmodule Mix.Tasks.Volt.LintTest do use ExUnit.Case, async: false import ExUnit.CaptureIO diff --git a/test/support/volt/builder_case.exs b/test/support/volt/builder_case.ex similarity index 100% rename from test/support/volt/builder_case.exs rename to test/support/volt/builder_case.ex diff --git a/test/support/volt/dev_server_case.exs b/test/support/volt/dev_server_case.ex similarity index 100% rename from test/support/volt/dev_server_case.exs rename to test/support/volt/dev_server_case.ex diff --git a/test/test_helper.exs b/test/test_helper.exs index 94b4500..701fa85 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,7 +1,3 @@ -Path.wildcard(Path.expand("support/**/*.exs", __DIR__)) -|> Enum.sort() -|> Enum.each(&Code.require_file/1) - ExUnit.start(exclude: [:integration]) Volt.Test.ExUnit.install(root: "test/volt/test/fixtures", include: ["core_api.test.ts"]) diff --git a/test/volt/assets_test.exs b/test/volt/assets_test.exs index 08af649..b4f87ae 100644 --- a/test/volt/assets_test.exs +++ b/test/volt/assets_test.exs @@ -9,6 +9,24 @@ defmodule Volt.AssetsTest do :ok end + @tag :tmp_dir + test "prepares a URL module without writing its referenced asset", %{tmp_dir: root} do + source = Path.join(root, "logo.svg") + outdir = Path.join(root, "output") + File.write!(source, "") + opts = [url: true, root: root, outdir: outdir, prefix: "https://cdn.example/assets"] + + assert {:ok, prepared} = Volt.Assets.prepare_js_module(source, opts) + assert [%Volt.Builder.Artifact{file: file, content: ""}] = prepared.artifacts + assert [%Volt.Builder.Asset{src: "logo.svg", file: ^file}] = prepared.assets + assert prepared.code =~ "https://cdn.example/assets/#{file}" + refute File.exists?(outdir) + + assert {:ok, emitted} = Volt.Assets.emit_js_module(source, opts) + assert emitted == Map.take(prepared, [:code, :assets]) + assert File.read!(Path.join(outdir, file)) == "" + end + describe "asset?/1" do test "recognizes image types" do assert Volt.Assets.asset?("photo.jpg") diff --git a/test/volt/build/layout_test.exs b/test/volt/build/layout_test.exs new file mode 100644 index 0000000..165c494 --- /dev/null +++ b/test/volt/build/layout_test.exs @@ -0,0 +1,17 @@ +defmodule Volt.Build.LayoutTest do + use ExUnit.Case, async: true + + alias Volt.Build.Layout + + test "asset directory prefixes both layout presets" do + assert Layout.new(:flat, "assets") == %Layout{scripts: "assets", styles: "assets"} + assert Layout.new(:split, "assets") == %Layout{scripts: "assets/js", styles: "assets/css"} + assert Layout.new(:flat) == %Layout{scripts: "", styles: ""} + end + + test "asset directory cannot escape the publication root" do + for path <- ["/assets", "../assets", "a/../assets", "C:\\assets"] do + assert_raise ArgumentError, fn -> Layout.new(:flat, path) end + end + end +end diff --git a/test/volt/build_test.exs b/test/volt/build_test.exs new file mode 100644 index 0000000..bb29470 --- /dev/null +++ b/test/volt/build_test.exs @@ -0,0 +1,366 @@ +defmodule Volt.BuildTest do + use ExUnit.Case, async: false + + @moduletag :tmp_dir + + setup %{tmp_dir: tmp} do + root = Path.join(tmp, "assets") + outdir = Path.join(tmp, "dist/assets") + File.mkdir_p!(root) + File.write!(Path.join(root, "app.ts"), "document.body.dataset.ready = 'true'") + File.write!(Path.join(root, "styles.css"), ~S|@import "tailwindcss" source(none);|) + File.write!(Path.join(root, "page.html"), ~S|
|) + + {:ok, root: root, outdir: outdir} + end + + test "shared output manifest includes only emitted chunk identities", %{ + root: root, + outdir: outdir + } do + File.write!( + Path.join(root, "one.ts"), + "import {value} from 'external-lib'; console.log(value); export const load = () => import('external-lazy')" + ) + + File.write!( + Path.join(root, "two.ts"), + "import {value} from 'external-lib'; console.log(value)" + ) + + assert {:ok, result} = + Volt.build( + entry: [Path.join(root, "one.ts"), Path.join(root, "two.ts")], + root: root, + outdir: outdir, + tailwind: [], + format: :esm, + minify: false, + external: ["external-lib", "external-lazy"] + ) + + for {_key, entry} <- result.manifest do + for reference <- entry.imports ++ entry.dynamicImports do + assert Map.has_key?(result.manifest, reference) + end + + refute "external-lib" in entry.imports + refute "external-lazy" in entry.dynamicImports + end + end + + test "nested assets keep public files and manifest at the publication root", %{ + root: root, + outdir: outdir + } do + public = Path.join(root, "public") + File.mkdir_p!(public) + File.write!(Path.join(public, "robots.txt"), "public") + + for layout <- [:flat, :split] do + destination = Path.join(outdir, Atom.to_string(layout)) + + assert {:ok, result} = + Volt.build( + entry: Path.join(root, "app.ts"), + root: root, + outdir: destination, + assets_dir: "assets", + output_layout: layout, + public_dir: public, + tailwind: [], + hash: false + ) + + suffix = if layout == :flat, do: "assets/app.js", else: "assets/js/app.js" + assert result.manifest["app.js"].file == suffix + assert File.regular?(Path.join(destination, suffix)) + assert File.regular?(Path.join(destination, "manifest.json")) + assert File.read!(Path.join(destination, "robots.txt")) == "public" + end + end + + test "public destination is independent of asset layout", %{root: root, outdir: outdir} do + public = Path.join(root, "public") + File.mkdir_p!(public) + File.write!(Path.join(public, "robots.txt"), "public") + + for layout <- [:flat, :split] do + destination = Path.join(outdir, Atom.to_string(layout)) + + assert {:ok, _} = + Volt.build( + entry: Path.join(root, "app.ts"), + root: root, + outdir: destination, + output_layout: layout, + public_dir: public, + tailwind: [] + ) + + assert File.read!(Path.join(destination, "robots.txt")) == "public" + end + end + + test "returns every standalone stylesheet", %{root: root, outdir: outdir} do + entries = + for name <- ["first", "second"] do + path = Path.join(root, "#{name}.css") + File.write!(path, ".#{name} { color: red }") + path + end + + assert {:ok, result} = + Volt.build(entry: entries, root: root, outdir: outdir, tailwind: [], hash: false) + + assert Enum.map(result.styles, &Path.basename(&1.path)) == ["first.css", "second.css"] + assert result.assets.styles == result.styles + assert Enum.all?(result.styles, &File.regular?(&1.path)) + end + + test "flat output deduplicates assets shared by Tailwind and JavaScript", %{ + root: root, + outdir: outdir + } do + File.write!(Path.join(root, "logo.svg"), "") + File.write!(Path.join(root, "app.ts"), "import logo from './logo.svg?url'; console.log(logo)") + + File.write!( + Path.join(root, "styles.css"), + "@import 'tailwindcss' source(none); .logo { background: url('./logo.svg') }" + ) + + assert {:ok, result} = + Volt.build( + entry: Path.join(root, "app.ts"), + root: root, + outdir: outdir, + output_layout: :flat, + hash: false, + minify: false, + tailwind: [css: Path.join(root, "styles.css")] + ) + + assert %Volt.Builder.ManifestEntry{file: file} = result.manifest["logo.svg"] + assert File.read!(Path.join(outdir, file)) == "" + assert [_] = Path.wildcard(Path.join(outdir, "logo-*.svg")) + end + + test "public files cannot overwrite compiled output", %{root: root, outdir: outdir} do + public = Path.join(root, "public") + File.mkdir_p!(Path.join(public, "js")) + File.write!(Path.join(public, "js/app.js"), "public collision") + + opts = [ + entry: Path.join(root, "app.ts"), + root: root, + outdir: outdir, + hash: false, + minify: false, + sourcemap: false, + tailwind: [] + ] + + assert {:ok, _} = Volt.build(opts) + previous = File.read!(Path.join(outdir, "js/app.js")) + + assert {:error, {:output_collision, "js/app.js"}} = + Volt.build(Keyword.put(opts, :public_dir, public)) + + assert File.read!(Path.join(outdir, "js/app.js")) == previous + end + + test "later entry failure leaves the previous build untouched", %{root: root, outdir: outdir} do + opts = [ + root: root, + outdir: outdir, + hash: false, + minify: false, + sourcemap: false, + tailwind: [] + ] + + entry = Path.join(root, "app.ts") + assert {:ok, _} = Volt.build([entry: entry] ++ opts) + + snapshot = fn -> + Path.wildcard(Path.join(outdir, "**/*")) + |> Enum.filter(&File.regular?/1) + |> Map.new(&{&1, File.read!(&1)}) + end + + before = snapshot.() + File.write!(entry, "console.log('replacement')") + bad = Path.join(root, "broken.ts") + File.write!(bad, "export const = ;") + assert {:error, _} = Volt.build([entry: [entry, bad]] ++ opts) + assert snapshot.() == before + end + + test "flat Tailwind collision preserves previous output", %{root: root, outdir: outdir} do + plain = Path.join(root, "plain.css") + File.write!(plain, "body { color: red }") + + opts = [ + entry: plain, + root: root, + outdir: outdir, + output_layout: :flat, + hash: false, + tailwind: [] + ] + + assert {:ok, _} = Volt.build(opts) + before = File.read!(Path.join(outdir, "plain.css")) + manifest = File.read!(Path.join(outdir, "manifest.json")) + + assert {:error, {:output_collision, "plain.css"}} = + Volt.build( + Keyword.put(opts, :tailwind, css: Path.join(root, "styles.css"), name: "plain") + ) + + assert File.read!(Path.join(outdir, "plain.css")) == before + assert File.read!(Path.join(outdir, "manifest.json")) == manifest + end + + test "builds scripts and Tailwind into one manifest", %{root: root, outdir: outdir} do + assert {:ok, result} = + Volt.build( + entry: Path.join(root, "app.ts"), + root: root, + outdir: outdir, + hash: false, + minify: false, + sourcemap: false, + tailwind: [ + css: Path.join(root, "styles.css"), + name: "site", + sources: [%{base: root, pattern: "**/*.html"}] + ] + ) + + assert File.regular?(Path.join(outdir, "js/app.js")) + assert File.regular?(Path.join(outdir, "css/site.css")) + assert File.read!(Path.join(outdir, "css/site.css")) =~ ".grid" + + assert [%Volt.Builder.OutputFile{path: style_path}] = result.styles + assert style_path == Path.join(outdir, "css/site.css") + assert result.assets.manifest["app.js"].file == "js/app.js" + assert result.manifest["site.css"].file == "css/site.css" + + assert Enum.all?(result.manifest, fn {_key, entry} -> + match?(%Volt.Builder.ManifestEntry{}, entry) + end) + + manifest = outdir |> Path.join("manifest.json") |> File.read!() |> Jason.decode!() + + assert manifest["app.js"]["file"] == "js/app.js" + assert manifest["site.css"]["file"] == "css/site.css" + refute File.exists?(Path.join(outdir, "js/manifest.json")) + refute File.exists?(Path.join(outdir, "css/manifest.json")) + end + + test "shared chunk references resolve through the manifest in both layouts", %{ + root: root, + outdir: outdir + } do + File.write!( + Path.join(root, "shared.ts"), + "export const shared = () => console.log('shared');" + ) + + entries = + for name <- ["one", "two"] do + path = Path.join(root, "#{name}.ts") + File.write!(path, "import {shared} from './shared'; shared();") + path + end + + for layout <- [:flat, :split], hash <- [false, true] do + output = Path.join(outdir, "#{layout}-#{hash}") + + assert {:ok, result} = + Volt.build( + entry: entries, + root: root, + outdir: output, + output_layout: layout, + hash: hash, + format: :esm, + code_splitting: true, + minify: false, + sourcemap: false, + tailwind: [] + ) + + assert [reference] = result.manifest["one.js"].imports + assert result.manifest["two.js"].imports == [reference] + assert %Volt.Builder.ManifestEntry{file: file} = result.manifest[reference] + assert File.regular?(Path.join(output, file)) + + assert Volt.Preload.tags(Path.join(output, "manifest.json"), entry: "one.js") == + ~s() + end + end + + test "flat output resolves every emitted manifest path", %{root: root, outdir: outdir} do + assert {:ok, result} = + Volt.build( + entry: Path.join(root, "app.ts"), + root: root, + outdir: outdir, + output_layout: :flat, + hash: false, + minify: false, + sourcemap: false, + tailwind: [ + css: Path.join(root, "styles.css"), + sources: [%{base: root, pattern: "**/*.html"}] + ] + ) + + assert result.manifest["app.js"].file == "app.js" + assert result.manifest["styles.css"].file == "styles.css" + + for {_key, %Volt.Builder.ManifestEntry{file: file}} <- result.manifest do + assert File.regular?(Path.join(outdir, file)) + end + + refute File.dir?(Path.join(outdir, "js")) + refute File.dir?(Path.join(outdir, "css")) + end + + test "rejects colliding manifest identities", %{root: root, outdir: outdir} do + plain_css = Path.join(root, "plain.css") + File.write!(plain_css, "body { color: red }") + + assert {:error, {:manifest_collision, ["plain.css"]}} = + Volt.build( + entry: plain_css, + root: root, + outdir: outdir, + hash: true, + tailwind: [css: Path.join(root, "styles.css"), name: "plain"] + ) + + refute File.exists?(Path.join(outdir, "manifest.json")) + end + + test "builds assets without Tailwind", %{root: root, outdir: outdir} do + assert {:ok, result} = + Volt.build( + entry: Path.join(root, "app.ts"), + root: root, + outdir: outdir, + hash: false, + minify: false, + sourcemap: false, + tailwind: [] + ) + + assert result.styles == [] + assert result.assets.css == nil + assert File.regular?(Path.join(outdir, "js/app.js")) + refute File.exists?(Path.join(outdir, "css/site.css")) + end +end diff --git a/test/volt/builder/artifact_test.exs b/test/volt/builder/artifact_test.exs new file mode 100644 index 0000000..d02e483 --- /dev/null +++ b/test/volt/builder/artifact_test.exs @@ -0,0 +1,39 @@ +defmodule Volt.Builder.ArtifactTest do + use ExUnit.Case, async: true + + alias Volt.Builder.Artifact + + test "asset identity is derived from content and preserves the extension" do + content = <<0, 255, 42>> + hash = Volt.Builder.Naming.hash(content) + + assert Artifact.asset("/source/fonts/site.woff2", content) == + %Artifact{file: "site-#{hash}.woff2", content: content} + + assert Artifact.asset("/another/site.woff2", content) == + Artifact.asset("/source/fonts/site.woff2", content) + + refute Artifact.asset("site.woff2", content).file == + Artifact.asset("site.woff2", content <> <<1>>).file + end + + test "prepares JavaScript and its sourcemap without filesystem output" do + assert Artifact.javascript("app.js", "run();", "{}") == [ + %Artifact{file: "app.js", content: "run();\n//# sourceMappingURL=app.js.map\n"}, + %Artifact{file: "app.js.map", content: "{}"} + ] + end + + test "hidden sourcemaps are emitted without a reference" do + assert Artifact.javascript("app.js", "run();", "{}", hidden: true) == [ + %Artifact{file: "app.js", content: "run();"}, + %Artifact{file: "app.js.map", content: "{}"} + ] + end + + test "JavaScript without a sourcemap produces only one artifact" do + assert Artifact.javascript("app.js", "run();", nil) == [ + %Artifact{file: "app.js", content: "run();"} + ] + end +end diff --git a/test/volt/builder/manifest_entry_test.exs b/test/volt/builder/manifest_entry_test.exs new file mode 100644 index 0000000..f7cfcd2 --- /dev/null +++ b/test/volt/builder/manifest_entry_test.exs @@ -0,0 +1,36 @@ +defmodule Volt.Builder.ManifestEntryTest do + use ExUnit.Case, async: true + + alias Volt.Builder.ManifestEntry + + test "stylesheet traversal is dependency-first, deduplicated, and cycle-safe" do + manifest = %{ + "app" => %ManifestEntry{imports: ["a", "b"], css: ["app.css"], dynamicImports: ["lazy"]}, + "a" => %ManifestEntry{imports: ["b"], css: ["a.css"]}, + "b" => %ManifestEntry{imports: ["a"], css: ["shared.css", "a.css"]}, + "lazy" => %ManifestEntry{css: ["lazy.css"]} + } + + assert ManifestEntry.stylesheets(manifest, "app") == ["shared.css", "a.css", "app.css"] + end + + test "output prefixes do not alter manifest references or source identity" do + entry = %ManifestEntry{ + src: "src/app.ts", + file: "app-hash.js", + imports: ["shared"], + dynamicImports: ["lazy"], + css: ["app-hash.css"], + assets: ["logo-hash.svg"] + } + + assert ManifestEntry.prefix_paths(entry, "js") == %ManifestEntry{ + src: "src/app.ts", + file: "js/app-hash.js", + imports: ["shared"], + dynamicImports: ["lazy"], + css: ["js/app-hash.css"], + assets: ["js/logo-hash.svg"] + } + end +end diff --git a/test/volt/builder/naming_test.exs b/test/volt/builder/naming_test.exs index 09f79c0..43c7dd1 100644 --- a/test/volt/builder/naming_test.exs +++ b/test/volt/builder/naming_test.exs @@ -3,6 +3,12 @@ defmodule Volt.Builder.NamingTest do alias Volt.Builder.Naming + test "preserves eight-character lowercase SHA-256 names" do + assert Naming.hash("") == "e3b0c442" + assert Naming.hash("hello") == "2cf24dba" + assert Naming.hash(["he", "llo"]) == "2cf24dba" + end + test "replaces characters that are invalid in Windows filenames" do assert Naming.file_path("virtual:volt/test?|.js") == "virtual_volt/test__entry__.js" diff --git a/test/volt/builder/output_test.exs b/test/volt/builder/output_test.exs new file mode 100644 index 0000000..99d310c --- /dev/null +++ b/test/volt/builder/output_test.exs @@ -0,0 +1,57 @@ +defmodule Volt.Builder.OutputTest do + use ExUnit.Case, async: true + + @moduletag :tmp_dir + + test "successful preparation returns output without creating its directory", %{tmp_dir: root} do + outdir = Path.join(root, "unpublished") + + context = %Volt.Builder.BuildContext{ + outdir: outdir, + hash: false, + ctx: %Volt.Builder.OutputContext{}, + bundle_opts: [format: :esm, minify: false, sourcemap: false] + } + + assert {:ok, result, plan} = + Volt.Builder.Output.prepare_single( + Path.join(root, "app.js"), + "app", + %Volt.Builder.Compiled{ + scripts: [{"app.js", "console.log('prepared');"}], + styles: ["body { color: red }"] + }, + context + ) + + assert result.js.path == Path.join(outdir, "app.js") + assert Enum.map(plan.artifacts, & &1.file) == ["app.css", "app.js"] + refute File.exists?(outdir) + end + + test "late CSS failure leaves the existing script untouched", %{tmp_dir: root} do + output = Path.join(root, "app.js") + File.write!(output, "previous generation") + + context = %Volt.Builder.BuildContext{ + outdir: root, + hash: false, + ctx: %Volt.Builder.OutputContext{}, + bundle_opts: [format: :esm, minify: false, sourcemap: false] + } + + assert {:error, {:css_compile_failed, _}} = + Volt.Builder.Output.build_single( + Path.join(root, "app.js"), + "app", + %Volt.Builder.Compiled{ + scripts: [{"app.js", "console.log('next generation');"}], + styles: ["a { color: } }"] + }, + context + ) + + assert File.read!(output) == "previous generation" + assert File.ls!(root) == ["app.js"] + end +end diff --git a/test/volt/builder/plan_test.exs b/test/volt/builder/plan_test.exs new file mode 100644 index 0000000..b8deefc --- /dev/null +++ b/test/volt/builder/plan_test.exs @@ -0,0 +1,92 @@ +defmodule Volt.Builder.PlanTest do + use ExUnit.Case, async: true + + alias Volt.Builder.{Artifact, Plan, Writer} + + @moduletag :tmp_dir + + test "validates manifest keys independently of output filenames" do + {:ok, plan} = + Plan.new([ + %Artifact{file: "js/main-hash.js", content: ""}, + %Artifact{file: "js/shared-hash.js", content: ""} + ]) + + manifest = %{ + "main" => %Volt.Builder.ManifestEntry{file: "js/main-hash.js", imports: ["shared"]}, + "shared" => %Volt.Builder.ManifestEntry{file: "js/shared-hash.js", imports: ["main"]} + } + + assert :ok = Plan.validate_manifest(plan, manifest) + + broken = + Map.put(manifest, "main", %Volt.Builder.ManifestEntry{ + file: "js/main-hash.js", + imports: ["js/shared-hash.js"], + dynamicImports: ["lazy"], + css: ["missing.css"], + assets: ["missing.svg"] + }) + + assert {:error, {:invalid_manifest, errors}} = Plan.validate_manifest(plan, broken) + assert {:missing_manifest_entry, "main", "js/shared-hash.js"} in errors + assert {:missing_manifest_entry, "main", "lazy"} in errors + assert {:missing_artifact, "main", "missing.css"} in errors + assert {:missing_artifact, "main", "missing.svg"} in errors + assert length(errors) == 4 + end + + test "deduplicates identical outputs and orders the plan" do + a = %Artifact{file: "a.js", content: "a"} + b = %Artifact{file: "nested/b.js", content: "b"} + assert {:ok, %Plan{artifacts: [^a, ^b]}} = Plan.new([b, a, b]) + end + + test "rejects conflicting output before replacing a previous file", %{tmp_dir: root} do + previous = Path.join(root, "app.js") + File.write!(previous, "previous") + + result = + with {:ok, plan} <- + Plan.new([ + %Artifact{file: "app.js", content: "first"}, + %Artifact{file: "app.js", content: "second"} + ]) do + Writer.write_plan(root, plan) + end + + assert result == {:error, {:output_collision, "app.js"}} + assert File.read!(previous) == "previous" + end + + test "rejects unsafe and ambiguous relative paths" do + for file <- [ + "", + ".", + "..", + "../app.js", + "/app.js", + "nested/../app.js", + "nested//app.js", + "nested/./app.js", + "C:\\app.js", + "nested\\app.js", + "bad\0.js" + ] do + assert {:error, {:invalid_output_path, ^file}} = + Plan.new([%Artifact{file: file, content: ""}]) + end + end + + test "writes all validated artifacts including nested outputs", %{tmp_dir: root} do + assert {:ok, plan} = + Plan.new([ + %Artifact{file: "js/app.js", content: "run();"}, + %Artifact{file: "css/app.css", content: "body{}"} + ]) + + assert :ok = Writer.write_plan(root, plan) + assert File.read!(Path.join(root, "js/app.js")) == "run();" + assert File.read!(Path.join(root, "css/app.css")) == "body{}" + end +end diff --git a/test/volt/builder/publication_test.exs b/test/volt/builder/publication_test.exs new file mode 100644 index 0000000..136e2b4 --- /dev/null +++ b/test/volt/builder/publication_test.exs @@ -0,0 +1,67 @@ +defmodule Volt.Builder.PublicationTest do + use ExUnit.Case, async: true + + alias Volt.Builder.{Artifact, Plan, Publication} + @moduletag :tmp_dir + + test "rejects destination symlinks before replacing any files", %{tmp_dir: root} do + outdir = Path.join(root, "output") + outside = Path.join(root, "outside") + File.mkdir_p!(outdir) + File.mkdir_p!(outside) + File.write!(Path.join(outdir, "app.js"), "previous") + File.ln_s!(outside, Path.join(outdir, "linked")) + + {:ok, plan} = + Plan.new([ + %Artifact{file: "app.js", content: "replacement"}, + %Artifact{file: "linked/escape.js", content: "escape"} + ]) + + assert {:error, {:symlink_destination, _}} = Publication.write(outdir, plan) + assert File.read!(Path.join(outdir, "app.js")) == "previous" + assert File.ls!(outside) == [] + end + + test "staging failure leaves existing output unchanged", %{tmp_dir: root} do + outdir = Path.join(root, "output") + File.mkdir_p!(outdir) + File.write!(Path.join(outdir, "app.js"), "previous") + # A file/directory conflict is a deterministic failure without permission assumptions. + plan = %Plan{ + artifacts: [ + %Artifact{file: "app.js", content: "replacement"}, + %Artifact{file: "nested", content: "file"}, + %Artifact{file: "nested/child.js", content: "child"} + ] + } + + assert {:error, {:staging_failed, "nested/child.js", _}} = Publication.write(outdir, plan) + assert File.read!(Path.join(outdir, "app.js")) == "previous" + assert File.ls!(root) == ["output"] + end + + test "commit failure does not replace manifest", %{tmp_dir: root} do + outdir = Path.join(root, "output") + File.mkdir_p!(Path.join(outdir, "blocked.js")) + File.write!(Path.join(outdir, "manifest.json"), "previous") + + {:ok, plan} = + Plan.new([ + %Artifact{file: "blocked.js", content: "next"}, + %Artifact{file: "manifest.json", content: "next manifest"} + ]) + + assert {:error, {:publication_failed, "blocked.js", _}} = Publication.write(outdir, plan) + assert File.read!(Path.join(outdir, "manifest.json")) == "previous" + assert File.ls!(root) == ["output"] + end + + test "successful publication preserves unrelated static files", %{tmp_dir: root} do + File.write!(Path.join(root, "robots.txt"), "keep") + {:ok, plan} = Plan.new([%Artifact{file: "app.js", content: "new"}]) + assert :ok = Publication.write(root, plan) + assert File.read!(Path.join(root, "robots.txt")) == "keep" + assert File.read!(Path.join(root, "app.js")) == "new" + end +end diff --git a/test/volt/builder/output/css_test.exs b/test/volt/builder/stylesheets_test.exs similarity index 82% rename from test/volt/builder/output/css_test.exs rename to test/volt/builder/stylesheets_test.exs index 6ba1b87..c0712b2 100644 --- a/test/volt/builder/output/css_test.exs +++ b/test/volt/builder/stylesheets_test.exs @@ -1,4 +1,4 @@ -defmodule Volt.Builder.Output.CSSTest do +defmodule Volt.Builder.StylesheetsTest do use ExUnit.Case, async: false @fixture_dir Path.expand("volt-builder-css-test", System.tmp_dir!()) @@ -16,6 +16,56 @@ defmodule Volt.Builder.Output.CSSTest do end describe "CSS production builds" do + @tag :tmp_dir + test "emits assets from nested local and package imports", %{tmp_dir: root} do + File.mkdir_p!(Path.join(root, "nested")) + package = Path.join(root, "node_modules/theme") + File.mkdir_p!(package) + + File.write!( + Path.join(package, "package.json"), + ~s({"name":"theme","exports":{".":"./index.css"}}) + ) + + File.write!( + Path.join(package, "index.css"), + "@font-face { font-family: Test; src: url('./font.woff2') }" + ) + + File.write!(Path.join(package, "font.woff2"), <<0, 1, 2>>) + + File.write!( + Path.join(root, "nested/theme.css"), + ".logo { background: url('./logo.svg?v=1#mark') }" + ) + + File.write!(Path.join(root, "nested/logo.svg"), "") + entry = Path.join(root, "app.css") + File.write!(entry, "@import './nested/theme.css'; @import 'theme';") + output = Path.join(root, "dist") + + assert {:ok, result} = + Volt.Builder.build( + entry: entry, + root: root, + outdir: output, + node_modules: Path.join(root, "node_modules"), + hash: false, + minify: false, + asset_url_prefix: "https://cdn.example/assets" + ) + + css = File.read!(result.css.path) + + for source <- ["nested/logo.svg", "node_modules/theme/font.woff2"] do + file = Map.fetch!(result.manifest, source).file + assert File.regular?(Path.join(output, file)) + assert css =~ "https://cdn.example/assets/#{file}" + end + + assert css =~ "?v=1#mark" + end + test "CSS-only JS entry builds with sourcemap enabled" do File.write!(Path.join(@fixture_dir, "src/styles.css"), "body { color: red; }") File.write!(Path.join(@fixture_dir, "src/css_only.js"), "import './styles.css'") diff --git a/test/volt/builder/workers_and_assets_test.exs b/test/volt/builder/workers_and_assets_test.exs index 57239b0..b602b6e 100644 --- a/test/volt/builder/workers_and_assets_test.exs +++ b/test/volt/builder/workers_and_assets_test.exs @@ -2,6 +2,54 @@ defmodule Volt.Builder.WorkersAndAssetsTest do use Volt.TestSupport.BuilderCase describe "build/1 workers, assets, and externals" do + test "preserves worker manifest and asset metadata" do + entry = Path.join(@fixture_dir, "parent.ts") + worker = Path.join(@fixture_dir, "worker.ts") + File.write!(entry, "new Worker(new URL('./worker.ts', import.meta.url), {type: 'module'})") + File.write!(worker, "import logo from './logo.svg?url'; self.postMessage(logo)") + File.write!(Path.join(@fixture_dir, "logo.svg"), "") + + assert {:ok, result} = + Volt.Builder.build( + entry: entry, + outdir: @outdir, + root: @fixture_dir, + hash: false, + minify: false, + sourcemap: false + ) + + assert result.manifest["worker.js"].file == "worker.js" + assert %Volt.Builder.ManifestEntry{file: image} = result.manifest["logo.svg"] + assert image in result.manifest["worker.js"].assets + assert Enum.any?(result.chunks, &(Path.basename(&1.path) == "worker.js")) + assert Path.basename(result.js.path) == "parent.js" + end + + test "does not copy imported assets before a later CSS failure" do + source = Path.join(@fixture_dir, "asset_failure.ts") + + File.write!( + source, + "import image from './logo.svg?url'; import './broken.css'; console.log(image);" + ) + + File.write!(Path.join(@fixture_dir, "logo.svg"), "") + File.write!(Path.join(@fixture_dir, "broken.css"), "a { color: } }") + before_files = Path.wildcard(Path.join(@outdir, "**/*")) + + assert {:error, _} = + Volt.Builder.build( + entry: source, + outdir: @outdir, + hash: false, + minify: false, + sourcemap: false + ) + + assert Path.wildcard(Path.join(@outdir, "**/*")) == before_files + end + test "rewrites workers by importer path instead of basename" do File.mkdir_p!(Path.join(@fixture_dir, "src/a")) File.mkdir_p!(Path.join(@fixture_dir, "src/b")) diff --git a/test/volt/cache_test.exs b/test/volt/cache_test.exs index 41f13a8..b0fc709 100644 --- a/test/volt/cache_test.exs +++ b/test/volt/cache_test.exs @@ -6,6 +6,24 @@ defmodule Volt.CacheTest do :ok end + test "sessions isolate cached content and eviction" do + path = "/shared/app.css" + a = %Volt.DevServer.CacheEntry{code: "a"} + b = %Volt.DevServer.CacheEntry{code: "b"} + Volt.Cache.put(path, 1, a, :site_a) + Volt.Cache.put(path, 1, b, :site_b) + Volt.Cache.put(path <> "?import", 1, a, :site_a) + assert Volt.Cache.get(path, 1, :site_a) == a + assert Volt.Cache.get(path, 1, :site_b) == b + assert Volt.Cache.get(path, 1) == nil + Volt.Cache.evict_file(path, :site_a) + assert Volt.Cache.get_file(path, :site_a) == nil + assert Volt.Cache.get_file(path <> "?import", :site_a) == nil + assert Volt.Cache.get_file(path, :site_b) == b + Volt.Cache.clear_session(:site_b) + assert Volt.Cache.get_file(path, :site_b) == nil + end + test "get returns nil on miss" do assert Volt.Cache.get("/app.ts", 12345) == nil end diff --git a/test/volt/config/tailwind_test.exs b/test/volt/config/tailwind_test.exs new file mode 100644 index 0000000..258c1fc --- /dev/null +++ b/test/volt/config/tailwind_test.exs @@ -0,0 +1,43 @@ +defmodule Volt.Config.TailwindTest do + use ExUnit.Case, async: true + + test "normalizes disabled and default-enabled configuration" do + for value <- [false, nil, true] do + assert %Volt.Config.Tailwind{sources: [], css: nil} = Volt.Config.Tailwind.new(value) + end + + refute Volt.Config.Tailwind.enabled?(false) + refute Volt.Config.Tailwind.enabled?(nil) + assert Volt.Config.Tailwind.enabled?(true) + end + + test "normalizes a configured root" do + root = + Volt.Config.Tailwind.new( + css: "assets/styles.css", + name: "site", + dev_url: "/custom/site.css", + sources: [%{base: "lib", pattern: "**/*.heex"}] + ) + + assert root.css == Path.expand("assets/styles.css") + assert root.name == "site" + assert root.dev_url == "/custom/site.css" + assert root.sources == [%{base: "lib", pattern: "**/*.heex"}] + end + + test "derives identity from the CSS input" do + root = Volt.Config.Tailwind.new(css: "assets/styles.css") + + assert root.name == "styles" + assert root.dev_url == "/assets/css/styles.css" + end + + test "supports Tailwind defaults without a CSS input" do + root = Volt.Config.Tailwind.new([]) + + assert root.css == nil + assert root.name == "app" + assert root.dev_url == "/assets/css/app.css" + end +end diff --git a/test/volt/css/asset_url_rewriter_test.exs b/test/volt/css/asset_url_rewriter_test.exs index 700a5e8..cf17bb6 100644 --- a/test/volt/css/asset_url_rewriter_test.exs +++ b/test/volt/css/asset_url_rewriter_test.exs @@ -14,6 +14,23 @@ defmodule Volt.CSS.AssetURLRewriterTest do :ok end + @tag :tmp_dir + test "prepares rewritten URLs and binary artifacts without copying files", %{tmp_dir: root} do + File.write!(Path.join(root, "icon.svg"), "") + css = ~s|.a { background: url('./icon.svg?v=1#mark') } .b { background: url('./icon.svg') }| + + assert {:ok, result} = + Volt.CSS.AssetURLRewriter.prepare(css, Path.join(root, "app.css"), + prefix: "https://cdn.example/assets", + root: root + ) + + assert [%Volt.Builder.Artifact{file: file, content: ""}] = result.artifacts + assert [%Volt.Builder.Asset{src: "icon.svg", file: ^file}] = result.assets + assert result.code =~ "https://cdn.example/assets/#{file}?v=1#mark" + assert File.ls!(root) == ["icon.svg"] + end + test "rewrites relative url nodes through hashed assets" do File.write!(Path.join(@fixture_dir, "src/icons/logo.svg"), "") source_path = Path.join(@fixture_dir, "src/app.css") diff --git a/test/volt/css/imports_test.exs b/test/volt/css/imports_test.exs new file mode 100644 index 0000000..54ccd33 --- /dev/null +++ b/test/volt/css/imports_test.exs @@ -0,0 +1,50 @@ +defmodule Volt.CSS.ImportsTest do + use ExUnit.Case, async: true + + @moduletag :tmp_dir + + test "nested imports retain asset origins without copying files", %{tmp_dir: root} do + File.mkdir_p!(Path.join(root, "nested/deep")) + + File.write!( + Path.join(root, "nested/theme.css"), + "@import './deep/fonts.css'; .logo { background: url('./logo.svg?v=1#mark') }" + ) + + File.write!( + Path.join(root, "nested/deep/fonts.css"), + "@font-face { font-family: Test; src: url('./font.woff2') }" + ) + + assert {:ok, css} = + Volt.CSS.Imports.inline( + "@import './nested/theme.css';", + Path.join(root, "app.css"), + [] + ) + + assert css =~ "nested/logo.svg?v=1#mark" + assert css =~ "nested/deep/font.woff2" + refute css =~ "@import" + end + + test "package imports rebase relative to the package stylesheet", %{tmp_dir: root} do + package = Path.join(root, "node_modules/theme") + File.mkdir_p!(package) + + File.write!( + Path.join(package, "package.json"), + ~s({"name":"theme","exports":{".":"./index.css"}}) + ) + + File.write!(Path.join(package, "index.css"), ".theme { background: url('./logo.svg') }") + + assert {:ok, css} = + Volt.CSS.Imports.inline("@import 'theme';", Path.join(root, "app.css"), + node_modules: Path.join(root, "node_modules") + ) + + assert css =~ "node_modules/theme/logo.svg" + refute css =~ "@import" + end +end diff --git a/test/volt/dev/assets_test.exs b/test/volt/dev/assets_test.exs new file mode 100644 index 0000000..14e8cb4 --- /dev/null +++ b/test/volt/dev/assets_test.exs @@ -0,0 +1,49 @@ +defmodule Volt.Dev.AssetsTest do + use ExUnit.Case, async: false + + @tag :tmp_dir + test "serves only resolved stylesheet assets in the owning session", %{tmp_dir: root} do + assets = Path.join(root, "assets") + package = Path.join(root, "node_modules/font") + File.mkdir_p!(assets) + File.mkdir_p!(package) + font = Path.join(package, "font.woff2") + File.write!(font, "font-content") + + File.write!( + Path.join(assets, "style.css"), + "@font-face { src: url('../node_modules/font/font.woff2?v=1#font'); }" + ) + + first = Volt.DevServer.init(root: assets, session: make_ref(), watch: true) + second = Volt.DevServer.init(root: assets, session: make_ref(), watch: true) + + on_exit(fn -> + Volt.Dev.stop(first.session) + Volt.Dev.stop(second.session) + end) + + css = Plug.Test.conn(:get, "/assets/style.css") |> Volt.DevServer.call(first) + assert css.status == 200 + assert css.resp_body =~ "/@volt/assets/" + assert css.resp_body =~ "?v=1#font" + tables = Volt.Dev.tables(first.session) + url = Volt.Dev.Assets.register(font, tables) + served = Plug.Test.conn(:get, url) |> Volt.DevServer.call(first) + assert served.status == 200 + assert served.resp_body == "font-content" + head = Plug.Test.conn(:head, url) |> Volt.DevServer.call(first) + assert head.status == 200 + assert head.resp_body == "" + assert (Plug.Test.conn(:get, url) |> Volt.DevServer.call(second)).status == 404 + + assert (Plug.Test.conn(:get, "/@volt/assets/arbitrary-path") + |> Volt.DevServer.call(first)).status == 404 + + owner = tables.owner + monitor = Process.monitor(owner) + Process.exit(owner, :kill) + assert_receive {:DOWN, ^monitor, :process, ^owner, :killed} + assert :ets.info(tables.assets) == :undefined + end +end diff --git a/test/volt/dev/session/call_test.exs b/test/volt/dev/session/call_test.exs new file mode 100644 index 0000000..242a175 --- /dev/null +++ b/test/volt/dev/session/call_test.exs @@ -0,0 +1,15 @@ +defmodule Volt.Dev.Session.CallTest do + use ExUnit.Case, async: true + + test "a terminated supervisor returns a retryable result" do + {:ok, supervisor} = Volt.Dev.Session.Supervisor.start_link([]) + Supervisor.stop(supervisor) + assert {:error, :session_restarting} = Volt.Dev.Session.Supervisor.tables(supervisor) + assert {:error, :session_restarting} = Volt.Dev.Session.Supervisor.stylesheet(supervisor) + end + + test "unrelated exits and exceptions remain visible" do + assert catch_exit(Volt.Dev.Session.Call.run(fn -> exit(:unexpected) end)) == :unexpected + assert_raise ArgumentError, fn -> Volt.Dev.Session.Call.run(fn -> raise ArgumentError end) end + end +end diff --git a/test/volt/dev/session/state_test.exs b/test/volt/dev/session/state_test.exs new file mode 100644 index 0000000..956d768 --- /dev/null +++ b/test/volt/dev/session/state_test.exs @@ -0,0 +1,44 @@ +defmodule Volt.Dev.Session.StateTest do + use ExUnit.Case, async: true + + test "graphs use owned tables and reject stale generation handles" do + owner = start_supervised!(Volt.Dev.Session.State) + tables = Volt.Dev.Session.State.tables(owner) + alias Volt.HMR.{ImportGraph, GlobGraph, StyleGraph, ModuleGraph, Boundary} + ImportGraph.update("/parent.js", ["./child.js"], tables) + GlobGraph.update("/parent.js", ["/pages/*.js"], tables) + StyleGraph.update("/app.css", ["/theme.css"], tables) + ModuleGraph.update_module("/child.js", "/child.js", "/child.js", [], session: tables) + + ModuleGraph.update_module("/parent.js", "/parent.js", "/parent.js", ["/child.js"], + session: tables + ) + + read = fn path -> if path == "/parent.js", do: "import.meta.hot.accept()", else: "" end + assert Boundary.find_boundary("/child.js", read, tables) == {:ok, "/parent.js"} + assert GlobGraph.dependents("/pages/page.js", tables) == ["/parent.js"] + assert StyleGraph.dependents("/theme.css", tables) == ["/app.css"] + assert :ets.info(tables.modules, :owner) == owner + stop_supervised!(Volt.Dev.Session.State) + assert_raise ArgumentError, fn -> ImportGraph.update("/parent.js", [], tables) end + assert_raise ArgumentError, fn -> GlobGraph.update("/parent.js", [], tables) end + assert_raise ArgumentError, fn -> StyleGraph.update("/app.css", [], tables) end + assert_raise ArgumentError, fn -> ModuleGraph.get_by_id("/child.js", tables) end + end + + test "forced owner termination destroys tables without a terminate callback" do + {:ok, owner} = Volt.Dev.Session.State.start_link([]) + Process.unlink(owner) + tables = Volt.Dev.Session.State.tables(owner) + :ets.insert(tables.cache, {:entry, "old"}) + monitor = Process.monitor(owner) + Process.exit(owner, :kill) + assert_receive {:DOWN, ^monitor, :process, ^owner, :killed} + + for table <- [tables.cache, tables.imports, tables.globs, tables.styles, tables.modules] do + assert :ets.info(table) == :undefined + end + + assert_raise ArgumentError, fn -> :ets.insert(tables.cache, {:entry, "stale write"}) end + end +end diff --git a/test/volt/dev/session/supervisor_test.exs b/test/volt/dev/session/supervisor_test.exs new file mode 100644 index 0000000..194fb90 --- /dev/null +++ b/test/volt/dev/session/supervisor_test.exs @@ -0,0 +1,135 @@ +defmodule Volt.Dev.Session.SupervisorTest do + use ExUnit.Case, async: true + + defmodule Witness do + use GenServer + def start_link(parent), do: GenServer.start_link(__MODULE__, parent) + + def init(parent) do + send(parent, {:dependent_started, self()}) + {:ok, parent} + end + end + + @tag :tmp_dir + test "Tailwind compilation stays inside the subtree", %{tmp_dir: root} do + identity = make_ref() + css = Path.join(root, "app.css") + File.write!(css, "@tailwind utilities;") + count = Registry.count(Volt.Tailwind.Registry) + + supervisor = + start_supervised!( + {Volt.Dev.Session.Supervisor, + identity: identity, + watcher: [ + root: root, + name: nil, + tailwind: true, + tailwind_css: css, + tailwind_sources: [], + tailwind_outdir: Path.join(root, "output") + ]} + ) + + children = Supervisor.which_children(supervisor) + {_, worker, _, _} = Enum.find(children, fn {id, _, _, _} -> id == Volt.Tailwind.Worker end) + {_, runtime, _, _} = Enum.find(children, fn {id, _, _, _} -> id == Volt.Tailwind.Runtime end) + assert :sys.get_state(worker).last_css + refute File.exists?(Path.join(root, "output")) + tables = Volt.Dev.Session.Supervisor.tables(supervisor) + assert tables.stylesheet_worker == worker + assert :sys.get_state(runtime).pid + assert Registry.count(Volt.Tailwind.Registry) == count + stop_supervised!(Volt.Dev.Session.Supervisor) + refute Process.alive?(worker) + refute Process.alive?(runtime) + end + + test "session subtree owns its lazy Tailwind runtime", %{test: test} do + identity = {test, make_ref()} + root = System.tmp_dir!() + + supervisor = + start_supervised!( + {Volt.Dev.Session.Supervisor, + identity: identity, watcher: [root: root, name: nil, tailwind: false]} + ) + + {_, runtime, _, _} = + Enum.find(Supervisor.which_children(supervisor), fn {id, _, _, _} -> + id == Volt.Tailwind.Runtime + end) + + assert :sys.get_state(runtime) == nil + assert {:ok, _} = Volt.Tailwind.Runtime.call("@tailwind utilities;", ["flex"], root, runtime) + compiler = :sys.get_state(runtime).pid + monitor = Process.monitor(compiler) + stop_supervised!(Volt.Dev.Session.Supervisor) + assert_receive {:DOWN, ^monitor, :process, ^compiler, _} + refute Process.alive?(runtime) + end + + test "real watcher restarts with replacement table handles", %{test: test} do + root = Path.join(System.tmp_dir!(), "volt-owned-#{System.unique_integer([:positive])}") + File.mkdir_p!(root) + on_exit(fn -> File.rm_rf!(root) end) + + supervisor = + start_supervised!( + {Volt.Dev.Session.Supervisor, + identity: {test, make_ref()}, watcher: [root: root, name: nil, tailwind: false]} + ) + + children = Supervisor.which_children(supervisor) + {_, owner, _, _} = Enum.find(children, fn {id, _, _, _} -> id == Volt.Dev.Session.State end) + + {_, watcher, _, _} = + Enum.find(children, fn {id, _, _, _} -> id == Volt.Dev.Session.Watcher end) + + tables = Volt.Dev.Session.State.tables(owner) + assert :sys.get_state(watcher).tables == tables + Volt.Cache.put("/app.js", 1, %Volt.DevServer.CacheEntry{code: "owned"}, tables) + assert Volt.Cache.get_file("/app.js", tables).code == "owned" + monitor = Process.monitor(watcher) + Process.exit(owner, :kill) + assert_receive {:DOWN, ^monitor, :process, ^watcher, :shutdown} + children = Supervisor.which_children(supervisor) + + {_, replacement, _, _} = + Enum.find(children, fn {id, _, _, _} -> id == Volt.Dev.Session.Watcher end) + + refute :sys.get_state(replacement).tables.generation == tables.generation + + assert_raise ArgumentError, fn -> + Volt.Cache.put("/app.js", 1, %Volt.DevServer.CacheEntry{}, tables) + end + end + + test "state owner failure restarts dependent children with a new generation" do + supervisor = start_supervised!({Volt.Dev.Session.Supervisor, children: [{Witness, self()}]}) + assert_receive {:dependent_started, first} + + {_, owner, _, _} = + Enum.find(Supervisor.which_children(supervisor), fn {id, _, _, _} -> + id == Volt.Dev.Session.State + end) + + old = Volt.Dev.Session.State.tables(owner) + monitor = Process.monitor(first) + Process.exit(owner, :kill) + assert_receive {:DOWN, ^monitor, :process, ^first, :shutdown} + assert_receive {:dependent_started, second} + refute first == second + + {_, replacement, _, _} = + Enum.find(Supervisor.which_children(supervisor), fn {id, _, _, _} -> + id == Volt.Dev.Session.State + end) + + new = Volt.Dev.Session.State.tables(replacement) + refute old.generation == new.generation + assert :ets.info(old.cache) == :undefined + assert :ets.info(new.cache, :owner) == replacement + end +end diff --git a/test/volt/dev_server/basic_test.exs b/test/volt/dev_server/basic_test.exs index 47294d4..c16a38a 100644 --- a/test/volt/dev_server/basic_test.exs +++ b/test/volt/dev_server/basic_test.exs @@ -26,7 +26,7 @@ defmodule Volt.DevServer.BasicTest do root = Path.join(@fixture_dir, "src") opts = Volt.DevServer.init(root: root, prefix: "/assets", watch: true) - key = {:watcher, :default, Path.expand(root)} + key = {:session, opts.session} assert [] = Registry.lookup(Volt.Dev.WatcherRegistry, key) @@ -35,10 +35,44 @@ defmodule Volt.DevServer.BasicTest do assert [{pid, _}] = Registry.lookup(Volt.Dev.WatcherRegistry, key) assert Process.alive?(pid) + assert %Volt.Dev.Session.Tables{} = Volt.Dev.tables(opts.session) + on_exit(fn -> Volt.Dev.stop(opts.session) end) + end + + test "normalizes configured Tailwind root into watcher options" do + previous = Application.get_env(:volt, :tailwind) + css = Path.join(@fixture_dir, "src/site.css") + + Application.put_env(:volt, :tailwind, + css: css, + name: "site", + dev_url: "/assets/css/site.css", + sources: [%{base: @fixture_dir, pattern: "**/*.html"}] + ) + on_exit(fn -> - if Process.alive?(pid), - do: DynamicSupervisor.terminate_child(Volt.Dev.WatcherSupervisor, pid) + if previous, + do: Application.put_env(:volt, :tailwind, previous), + else: Application.delete_env(:volt, :tailwind) end) + + opts = + Volt.DevServer.init( + root: Path.join(@fixture_dir, "src"), + prefix: "/assets", + watch: true + ) + + assert opts.watcher_opts[:tailwind] + assert opts.watcher_opts[:tailwind_css] == Path.expand(css) + assert opts.watcher_opts[:tailwind_name] == "site" + + assert opts.watcher_opts[:tailwind_sources] == [ + %{base: @fixture_dir, pattern: "**/*.html"} + ] + + assert opts.watcher_opts[:tailwind_url] == "/assets/css/site.css" + assert opts.watcher_opts[:tailwind_key] == {:profile, :default, Path.expand(css)} end test "passes through non-asset requests for downstream HTML plugs" do diff --git a/test/volt/dev_server/import_rewriting_test.exs b/test/volt/dev_server/import_rewriting_test.exs index 6a95b0a..fd46978 100644 --- a/test/volt/dev_server/import_rewriting_test.exs +++ b/test/volt/dev_server/import_rewriting_test.exs @@ -1,6 +1,31 @@ defmodule Volt.DevServer.ImportRewritingTest do use Volt.TestSupport.DevServerCase + defmodule VirtualAbsolute do + @behaviour Volt.Plugin + def name, do: "virtual-absolute" + + def load("fixture:entry", root: root), + do: + {:ok, + "import value from #{Jason.encode!(Path.join(root, "value.ts"))}; console.log(value);"} + + def load(_, _), do: nil + end + + @tag :tmp_dir + test "virtual entry absolute filesystem imports become served URLs", %{tmp_dir: root} do + File.write!(Path.join(root, "value.ts"), "export default 42") + + config = + Volt.DevServer.init(root: root, watch: false, plugins: [{VirtualAbsolute, root: root}]) + + conn = Plug.Test.conn(:get, "/@volt/virtual/fixture:entry") |> Volt.DevServer.call(config) + assert conn.status == 200 + assert conn.resp_body =~ "from \"/assets/value.ts\"" + refute conn.resp_body =~ Path.join(root, "value.ts") + end + describe "import rewriting" do test "rewrites relative imports to absolute paths" do File.write!(Path.join(@fixture_dir, "src/utils.ts"), "export const y = 1") diff --git a/test/volt/dev_server/requests_test.exs b/test/volt/dev_server/requests_test.exs index 430e04d..017185e 100644 --- a/test/volt/dev_server/requests_test.exs +++ b/test/volt/dev_server/requests_test.exs @@ -1,6 +1,262 @@ defmodule Volt.DevServer.RequestsTest do use Volt.TestSupport.DevServerCase + @tag :tmp_dir + test "Plug attaches to a CLI-owned session and serves current CSS ahead of a stale sink", %{ + tmp_dir: root + } do + css = Path.join(root, "app.css") + File.write!(css, ".current { color: red }") + sink = Path.join(root, "static") + + opts = [ + root: root, + tailwind: true, + tailwind_css: css, + tailwind_name: "site", + tailwind_sources: [], + tailwind_sink: sink + ] + + session = Volt.Dev.session_identity(opts) + on_exit(fn -> Volt.Dev.stop(session) end) + assert {:ok, watcher} = Volt.Dev.start(opts) + + config = %{ + Volt.DevServer.init(root: root, watch: false, session: session) + | stylesheet_url: "/assets/site.css", + stylesheet_source: css + } + + assert config.watcher_opts == nil + assert File.regular?(Path.join(sink, "site.css")) + File.write!(Path.join(sink, "site.css"), "stale disk output") + conn = Plug.Test.conn(:get, "/assets/site.css") |> Volt.DevServer.call(config) + assert conn.status == 200 + assert conn.resp_body =~ ".current" + refute conn.resp_body =~ "stale disk" + assert {:ok, ^watcher} = Volt.Dev.start(opts) + end + + @tag :tmp_dir + test "session CSS links to served nested assets without rewriting stored CSS", %{tmp_dir: root} do + session = make_ref() + File.mkdir_p!(Path.join(root, "css/nested")) + source = Path.join(root, "css/app.css") + File.write!(source, "@import './nested/theme.css';") + + File.write!( + Path.join(root, "css/nested/theme.css"), + ".logo { background: url('./logo.svg?v=1#mark') }" + ) + + File.write!(Path.join(root, "css/nested/logo.svg"), "") + + supervisor = + start_supervised!( + {Volt.Dev.Session.Supervisor, + identity: session, + watcher: [ + root: root, + session: session, + name: nil, + tailwind: true, + tailwind_css: source, + tailwind_sources: [] + ]} + ) + + tables = Volt.Dev.Session.Supervisor.tables(supervisor) + {:ok, stored} = Volt.Tailwind.Worker.stylesheet(tables.stylesheet_worker) + + config = %{ + Volt.DevServer.init(root: root, session: session, session_supervisor: supervisor) + | stylesheet_url: "/assets/site.css", + stylesheet_source: source + } + + response = Plug.Test.conn(:get, "/assets/site.css") |> Volt.DevServer.call(config) + assert response.status == 200 + assert response.resp_body =~ "/assets/css/nested/logo.svg?v=1#mark" + asset = Plug.Test.conn(:get, "/assets/css/nested/logo.svg?v=1") |> Volt.DevServer.call(config) + assert asset.status == 200 + assert asset.resp_body == "" + assert {:ok, ^stored} = Volt.Tailwind.Worker.stylesheet(tables.stylesheet_worker) + end + + @tag :tmp_dir + test "serves retained CSS without a disk artifact and preserves it on compiler error", %{ + tmp_dir: root + } do + session = make_ref() + + supervisor = + start_supervised!( + {Volt.Dev.Session.Supervisor, + identity: session, watcher: [root: root, session: session, name: nil, tailwind: false]} + ) + + {_, worker, _, _} = + Enum.find(Supervisor.which_children(supervisor), fn {id, _, _, _} -> + id == Volt.Tailwind.Worker + end) + + assert {:ok, css} = + Volt.Tailwind.build(worker: worker, css: ".retained { color: red }", sources: []) + + config = %{ + Volt.DevServer.init(root: root, session: session, session_supervisor: supervisor) + | stylesheet_url: "/assets/site.css" + } + + response = Plug.Test.conn(:get, "/assets/site.css") |> Volt.DevServer.call(config) + assert response.status == 200 + assert response.resp_body == css + assert Plug.Conn.get_resp_header(response, "content-type") == ["text/css; charset=utf-8"] + refute File.exists?(Path.join(root, "site.css")) + + assert {:error, _} = + Volt.Tailwind.build( + worker: worker, + css: "@import './missing.css';", + css_base: root, + sources: [] + ) + + response = Plug.Test.conn(:get, "/assets/site.css") |> Volt.DevServer.call(config) + assert response.resp_body == css + end + + defmodule PausedCompiler do + @behaviour Volt.Plugin + def name, do: "paused-compiler" + + def compile(_path, _source, _opts, parent: parent) do + send(parent, {:compiling, self()}) + + receive do + :continue -> {:ok, %Volt.Pipeline.Result{code: "console.log('compiled')", type: :js}} + end + end + end + + @tag :tmp_dir + test "restart during compilation cannot populate the replacement generation", %{tmp_dir: root} do + File.write!(Path.join(root, "app.ts"), "console.log('source')") + session = make_ref() + + supervisor = + start_supervised!( + {Volt.Dev.Session.Supervisor, + identity: session, watcher: [root: root, session: session, name: nil, tailwind: false]} + ) + + config = + Volt.DevServer.init( + root: root, + session: session, + session_supervisor: supervisor, + plugins: [{PausedCompiler, [parent: self()]}] + ) + + old = Volt.Dev.Session.Supervisor.tables(supervisor) + + request = + Task.async(fn -> Plug.Test.conn(:get, "/assets/app.ts") |> Volt.DevServer.call(config) end) + + assert_receive {:compiling, compiling} + monitor = Process.monitor(old.owner) + Process.exit(old.owner, :kill) + assert_receive {:DOWN, ^monitor, :process, _, :killed} + current = Volt.Dev.Session.Supervisor.tables(supervisor) + refute current.generation == old.generation + send(compiling, :continue) + assert Task.await(request).status == 503 + assert Volt.Cache.get_file(Path.join(root, "app.ts"), current) == nil + + fresh = + Task.async(fn -> Plug.Test.conn(:get, "/assets/app.ts") |> Volt.DevServer.call(config) end) + + assert_receive {:compiling, compiling} + send(compiling, :continue) + assert Task.await(fresh).status == 200 + assert Volt.Cache.get_file(Path.join(root, "app.ts"), current) + end + + @tag :tmp_dir + test "stale generation handles return a retryable response", %{tmp_dir: root} do + File.write!(Path.join(root, "app.ts"), "console.log('stale')") + owner = start_supervised!(Volt.Dev.Session.State) + tables = Volt.Dev.Session.State.tables(owner) + config = Volt.DevServer.init(root: root, tables: tables, watch: false) + stop_supervised!(Volt.Dev.Session.State) + conn = Plug.Test.conn(:get, "/assets/app.ts") |> Volt.DevServer.call(config) + assert conn.status == 503 + assert conn.halted + assert conn.resp_body =~ "retry" + end + + @tag :tmp_dir + test "automatic managed sessions serve identical URLs from distinct owned generations", %{ + tmp_dir: root + } do + sessions = + for label <- ["first", "second"] do + directory = Path.join(root, label) + File.mkdir_p!(directory) + File.write!(Path.join(directory, "app.ts"), "console.log('#{label}')") + session = make_ref() + on_exit(fn -> Volt.Dev.stop(session) end) + {session, directory, Volt.DevServer.init(root: directory, session: session, watch: true)} + end + + for {session, directory, config} <- sessions do + conn = Plug.Test.conn(:get, "/assets/app.ts") |> Volt.DevServer.call(config) + assert conn.status == 200 + assert conn.resp_body =~ Path.basename(directory) + tables = Volt.Dev.tables(session) + assert Volt.Cache.get_file(Path.join(directory, "app.ts"), tables) + assert Volt.Cache.get_file(Path.join(directory, "app.ts"), session) == nil + end + + [{first, _, config}, {second, _, _}] = sessions + first_tables = Volt.Dev.tables(first) + second_tables = Volt.Dev.tables(second) + refute first_tables.owner == second_tables.owner + assert :ok = Volt.Dev.stop(first) + assert :ets.info(first_tables.cache) == :undefined + assert :ets.info(second_tables.cache, :owner) == second_tables.owner + + assert Plug.Test.conn(:get, "/assets/app.ts") + |> Volt.DevServer.call(config) + |> Map.fetch!(:status) == 200 + + refute Volt.Dev.tables(first).generation == first_tables.generation + end + + @tag :tmp_dir + test "requests share owned tables with their sole watcher", %{tmp_dir: root} do + session = make_ref() + File.write!(Path.join(root, "app.ts"), "console.log('owned request')") + + supervisor = + start_supervised!( + {Volt.Dev.Session.Supervisor, + identity: session, watcher: [root: root, session: session, name: nil, tailwind: false]} + ) + + config = Volt.DevServer.init(root: root, session: session, session_supervisor: supervisor) + assert config.watcher_opts == nil + conn = Plug.Test.conn(:get, "/assets/app.ts") |> Volt.DevServer.call(config) + assert conn.status == 200 + assert conn.resp_body =~ "owned request" + tables = Volt.Dev.Session.Supervisor.tables(supervisor) + assert Volt.Cache.get_file(Path.join(root, "app.ts"), tables) + assert Volt.Cache.get_file(Path.join(root, "app.ts"), session) == nil + assert [_] = Volt.HMR.ModuleGraph.get_by_file(Path.join(root, "app.ts"), tables) + assert Volt.HMR.ModuleGraph.get_by_file(Path.join(root, "app.ts"), session) == [] + end + describe "non-matching paths" do test "passes through non-matching prefix" do conn = call_dev_server("/other/app.ts") diff --git a/test/volt/dev_test.exs b/test/volt/dev_test.exs index b391b8f..8966c5d 100644 --- a/test/volt/dev_test.exs +++ b/test/volt/dev_test.exs @@ -1,36 +1,168 @@ defmodule Volt.DevTest do use ExUnit.Case, async: false - test "starts one supervised watcher per profile and asset root" do - root = Path.expand("volt-dev-#{System.unique_integer([:positive])}", System.tmp_dir!()) - File.mkdir_p!(root) + @tag :tmp_dir + test "cached asset requests do not wait for the watcher to finish rebuilding", %{tmp_dir: root} do + File.write!(Path.join(root, "app.js"), "export default 1") + config = Volt.DevServer.init(root: root, watch: true) + request = fn -> Plug.Test.conn(:get, "/assets/app.js") |> Volt.DevServer.call(config) end + assert request.().status == 200 + assert {:ok, watcher} = Volt.Dev.start(config.watcher_opts) + :sys.suspend(watcher) + + try do + task = Task.async(request) + assert Task.await(task, 1_000).status == 200 + after + :sys.resume(watcher) + Volt.Dev.stop(config.session) + end + end + + @tag :tmp_dir + test "explicit compatibility sink is written by the session-owned worker", %{tmp_dir: root} do + source = Path.join(root, "app.css") + File.write!(source, ".sink { color: red }") + sink = Path.join(root, "static") + session = make_ref() + + assert {:ok, watcher} = + Volt.Dev.start( + session: session, + root: root, + tailwind: true, + tailwind_css: source, + tailwind_name: "site", + tailwind_sources: [], + tailwind_sink: sink + ) + + on_exit(fn -> Volt.Dev.stop(session) end) + tables = :sys.get_state(watcher).tables + assert {:ok, css} = Volt.Tailwind.Worker.stylesheet(tables.stylesheet_worker) + assert File.read!(Path.join(sink, "site.css")) == css + assert :sys.get_state(watcher).tailwind_outdir == sink + end - assert :ok = Volt.Dev.ensure_watcher(id: :default, root: root, tailwind: false) - assert [{pid, _}] = Registry.lookup(Volt.Dev.WatcherRegistry, {:watcher, :default, root}) + @tag :tmp_dir + test "Tailwind contexts are isolated and released with explicit sessions", %{tmp_dir: root} do + a = make_ref() + b = make_ref() + css = Path.join(root, "input.css") + File.write!(css, "@tailwind utilities;") + options = [root: root, tailwind: true, tailwind_css: css, tailwind_sources: []] on_exit(fn -> - if Process.alive?(pid) do - DynamicSupervisor.terminate_child(Volt.Dev.WatcherSupervisor, pid) - end + Volt.Dev.stop(a) + Volt.Dev.stop(b) + end) - File.rm_rf!(root) + assert {:ok, first} = + Volt.Dev.start([session: a, tailwind_outdir: Path.join(root, "a")] ++ options) + + assert {:ok, second} = + Volt.Dev.start([session: b, tailwind_outdir: Path.join(root, "b")] ++ options) + + first_worker = GenServer.whereis(:sys.get_state(first).config.tailwind_worker) + second_worker = GenServer.whereis(:sys.get_state(second).config.tailwind_worker) + refute first_worker == second_worker + assert :ok = Volt.Dev.stop(a) + refute Process.alive?(first_worker) + assert Process.alive?(second_worker) + assert :ok = Volt.Dev.stop(b) + refute Process.alive?(second_worker) + end + + @tag :tmp_dir + test "owner exit stops its watcher and clears its state", %{tmp_dir: root} do + session = make_ref() + + owner = + spawn(fn -> + receive do + :finish -> :ok + end + end) + + assert {:ok, watcher} = + Volt.Dev.start(session: session, root: root, owner: owner, tailwind: false) + + on_exit(fn -> + Process.exit(owner, :kill) + Volt.Dev.stop(session) end) - assert Process.alive?(pid) - assert :ok = Volt.Dev.ensure_watcher(id: :default, root: root, tailwind: false) - assert [{^pid, _}] = Registry.lookup(Volt.Dev.WatcherRegistry, {:watcher, :default, root}) + Volt.Cache.put("/app.js", 1, %Volt.DevServer.CacheEntry{code: "owned"}, session) + monitor = Process.monitor(watcher) + send(owner, :finish) + assert_receive {:DOWN, ^monitor, :process, ^watcher, :normal} + assert Volt.Cache.get_file("/app.js", session) == nil + # Supervisor calls are a barrier after processing the linked child's exit. + children = DynamicSupervisor.which_children(Volt.Dev.WatcherSupervisor) + refute Enum.any?(children, fn {_, pid, _, _} -> pid == watcher end) + end + + @tag :tmp_dir + test "concurrent starts share one watcher and normalized directory options", %{tmp_dir: root} do + session = make_ref() + opts = [session: session, root: root, tailwind: false] + on_exit(fn -> Volt.Dev.stop(session) end) + + results = + 1..8 + |> Task.async_stream(fn _ -> Volt.Dev.start(opts) end, timeout: 15_000) + |> Enum.to_list() + + pids = Enum.map(results, fn {:ok, {:ok, pid}} -> pid end) + assert [pid] = Enum.uniq(pids) + assert {:ok, ^pid} = Volt.Dev.start(opts ++ [watch_dirs: [], reload_dirs: []]) + assert :ok = Volt.Dev.stop(session) + refute Process.alive?(pid) + assert {:ok, replacement} = Volt.Dev.start(opts) + refute replacement == pid + assert Process.alive?(replacement) + end + + @tag :tmp_dir + test "explicit sessions reject conflicting roots and stop clears state", %{tmp_dir: root} do + session = make_ref() + opts = [session: session, root: root, tailwind: false] + assert {:ok, pid} = Volt.Dev.start(opts) + on_exit(fn -> Volt.Dev.stop(session) end) + assert {:ok, ^pid} = Volt.Dev.start(opts) + + assert {:error, :session_configuration_conflict} = + Volt.Dev.start(Keyword.put(opts, :root, Path.join(root, "other"))) + + Volt.Cache.put("/app.js", 1, %Volt.DevServer.CacheEntry{code: "session"}, session) + Volt.HMR.ImportGraph.update("/app.js", ["./child.js"], session) + Volt.HMR.ModuleGraph.update_module("/app.js", "/app.js", "/app.js", [], session: session) + assert :ok = Volt.Dev.stop(session) + refute Process.alive?(pid) + assert Volt.Cache.get_file("/app.js", session) == nil + assert Volt.HMR.ImportGraph.imports_of("/app.js", session) == [] + assert Volt.HMR.ModuleGraph.get_by_url("/app.js", session) == nil + assert :ok = Volt.Dev.stop(session) + end - assert :ok = Volt.Dev.ensure_watcher(id: :admin, root: root, tailwind: false) + test "starts one supervised watcher per profile and asset root" do + root = Path.expand("volt-dev-#{System.unique_integer([:positive])}", System.tmp_dir!()) + File.mkdir_p!(root) - assert [{profile_pid, _}] = - Registry.lookup(Volt.Dev.WatcherRegistry, {:watcher, :admin, root}) + default = Volt.Dev.session_identity(root: root) + admin = Volt.Dev.session_identity(root: root, id: :admin) on_exit(fn -> - if Process.alive?(profile_pid) do - DynamicSupervisor.terminate_child(Volt.Dev.WatcherSupervisor, profile_pid) - end + Volt.Dev.stop(default) + Volt.Dev.stop(admin) + File.rm_rf!(root) end) + assert {:ok, pid} = Volt.Dev.start(root: root, tailwind: false) + assert {:ok, ^pid} = Volt.Dev.start(id: :default, root: root, tailwind: false) + assert {:ok, profile_pid} = Volt.Dev.start(id: :admin, root: root, tailwind: false) + assert :sys.get_state(pid).tables == Volt.Dev.tables(default) + refute Volt.Dev.tables(default).owner == Volt.Dev.tables(admin).owner assert profile_pid != pid end end diff --git a/test/volt/hmr/boundary_test.exs b/test/volt/hmr/boundary_test.exs index 3d428c1..5ab694d 100644 --- a/test/volt/hmr/boundary_test.exs +++ b/test/volt/hmr/boundary_test.exs @@ -3,6 +3,38 @@ defmodule Volt.HMR.BoundaryTest do alias Volt.HMR.Boundary + test "boundary lookup isolates both served and fallback import graphs" do + alias Volt.HMR.{ImportGraph, ModuleGraph} + a = make_ref() + b = make_ref() + + on_exit(fn -> + for session <- [a, b] do + ImportGraph.clear_session(session) + ModuleGraph.clear_session(session) + end + end) + + child = "/session/child.js" + parent = "/session/parent.js" + + read = fn path -> + if path == parent, do: "import.meta.hot.accept()", else: "export default 1" + end + + ImportGraph.update(parent, ["./child.js"], a) + assert Boundary.find_boundary(child, read, a) == {:ok, parent} + assert Boundary.find_boundary(child, read, b) == :full_reload + ModuleGraph.update_module("/child.js", child, child, [], session: a) + ModuleGraph.update_module("/parent.js", parent, parent, [child], session: a) + ModuleGraph.update_module("/child.js", child, child, [], session: b) + assert Boundary.find_boundary(child, read, a) == {:ok, parent} + assert Boundary.find_boundary(child, read, b) == :full_reload + ModuleGraph.invalidate_file(child, 123, a) + assert ModuleGraph.get_by_id(child, a).last_invalidated_at == 123 + assert ModuleGraph.get_by_id(child, b).last_invalidated_at == nil + end + describe "self_accepting?/1" do test "detects import.meta.hot.accept()" do assert Boundary.self_accepting?(""" diff --git a/test/volt/hmr/glob_graph_test.exs b/test/volt/hmr/glob_graph_test.exs index b033417..5ce9172 100644 --- a/test/volt/hmr/glob_graph_test.exs +++ b/test/volt/hmr/glob_graph_test.exs @@ -8,6 +8,19 @@ defmodule Volt.HMR.GlobGraphTest do :ok end + test "isolates glob ownership, removal, and cleanup by session" do + importer = "/src/routes.ts" + GlobGraph.update(importer, ["/src/a/*.ts"], :a) + GlobGraph.update(importer, ["/src/b/*.ts"], :b) + assert GlobGraph.dependents("/src/a/page.ts", :a) == [importer] + assert GlobGraph.dependents("/src/a/page.ts", :b) == [] + assert GlobGraph.dependents("/src/a/page.ts") == [] + GlobGraph.remove(importer, :a) + assert GlobGraph.dependents("/src/b/page.ts", :b) == [importer] + GlobGraph.clear_session(:b) + assert GlobGraph.dependents("/src/b/page.ts", :b) == [] + end + test "finds importers whose glob patterns match a path" do GlobGraph.update("/src/routes.ts", ["/src/pages/*.ts"]) diff --git a/test/volt/hmr/style_graph_test.exs b/test/volt/hmr/style_graph_test.exs index 6fbf1f2..4f0ae15 100644 --- a/test/volt/hmr/style_graph_test.exs +++ b/test/volt/hmr/style_graph_test.exs @@ -6,6 +6,19 @@ defmodule Volt.HMR.StyleGraphTest do :ok end + test "transitive edges and cleanup remain inside their session" do + alias Volt.HMR.StyleGraph + StyleGraph.update("/app.css", ["/theme.css"], :a) + StyleGraph.update("/theme.css", ["/tokens.css"], :a) + StyleGraph.update("/app.css", ["/other.css"], :b) + assert Enum.sort(StyleGraph.dependents("/tokens.css", :a)) == ["/app.css", "/theme.css"] + assert StyleGraph.dependents("/tokens.css", :b) == [] + assert StyleGraph.dependents("/tokens.css") == [] + StyleGraph.clear_session(:a) + assert StyleGraph.dependencies_of("/app.css", :a) == [] + assert StyleGraph.dependents("/other.css", :b) == ["/app.css"] + end + test "tracks direct dependencies and transitive dependents" do app = "/app/app.css" theme = "/app/theme.css" diff --git a/test/volt/hmr_test.exs b/test/volt/hmr_test.exs index 8960d5a..c3769a5 100644 --- a/test/volt/hmr_test.exs +++ b/test/volt/hmr_test.exs @@ -6,6 +6,42 @@ defmodule Volt.HMRTest do :ok end + test "scoped broadcasts do not reach other sessions or default subscribers" do + parent = self() + + clients = + for session <- [:site_a, :site_b] do + pid = + spawn_link(fn -> + {:ok, _} = Volt.HMR.Socket.init(session: session) + send(parent, {:ready, self()}) + + receive do + :inspect -> send(parent, {:messages, self(), Process.info(self(), :messages)}) + end + end) + + assert_receive {:ready, ^pid} + {session, pid} + end + + Volt.HMR.style_update("/assets/app.css", session: :site_a) + for {_session, pid} <- clients, do: send(pid, :inspect) + + for {session, pid} <- clients do + assert_receive {:messages, ^pid, {:messages, messages}} + + expected = + if session == :site_a, + do: [{:volt_hmr, :update, %{path: "/assets/app.css", changes: ["style"]}}], + else: [] + + assert messages == expected + end + + refute_received {:volt_hmr, _, _} + end + test "broadcast sends HMR messages to connected clients" do assert :ok = Volt.HMR.broadcast(:update, %{path: "index.html", changes: ["full"]}) diff --git a/test/volt/integration/phoenix_example_test.exs b/test/volt/integration/phoenix_example_test.exs index 5cb71ea..d99d777 100644 --- a/test/volt/integration/phoenix_example_test.exs +++ b/test/volt/integration/phoenix_example_test.exs @@ -146,6 +146,7 @@ defmodule Volt.Integration.PhoenixExampleBuildTest do File.rm_rf!(Path.join(@outdir, "js")) File.rm_rf!(Path.join(@outdir, "css")) + File.rm(Path.join(@outdir, "manifest.json")) {output, status} = System.cmd("mix", ["volt.build", "--tailwind", "--hash", "--no-minify"], @@ -179,22 +180,19 @@ defmodule Volt.Integration.PhoenixExampleBuildTest do end test "produces valid manifest", %{build_status: 0} do - manifest = @outdir |> Path.join("js/manifest.json") |> File.read!() |> Jason.decode!() + manifest = manifest() assert Map.has_key?(manifest, "app.js") - assert manifest["app.js"]["file"] =~ ~r/^app-[a-f0-9]{8}\.js$/ - end - - test "produces valid Tailwind manifest", %{build_status: 0} do - manifest = @outdir |> Path.join("css/manifest.json") |> File.read!() |> Jason.decode!() - + assert manifest["app.js"]["file"] =~ ~r|^js/app-[a-f0-9]{8}\.js$| assert Map.has_key?(manifest, "app.css") - assert manifest["app.css"]["file"] =~ ~r/^app-[a-f0-9]{8}\.css$/ + assert manifest["app.css"]["file"] =~ ~r|^css/app-[a-f0-9]{8}\.css$| + refute File.exists?(Path.join(@outdir, "js/manifest.json")) + refute File.exists?(Path.join(@outdir, "css/manifest.json")) end test "produces Tailwind CSS with utility classes from heex templates", %{build_status: 0} do - manifest = @outdir |> Path.join("css/manifest.json") |> File.read!() |> Jason.decode!() - css = File.read!(Path.join([@outdir, "css", manifest["app.css"]["file"]])) + manifest = manifest() + css = File.read!(Path.join(@outdir, manifest["app.css"]["file"])) assert css =~ "rounded-2xl" assert css =~ "bg-amber-600" @@ -202,17 +200,17 @@ defmodule Volt.Integration.PhoenixExampleBuildTest do end test "produces Tailwind CSS with Phoenix colocated CSS", %{build_status: 0} do - manifest = @outdir |> Path.join("css/manifest.json") |> File.read!() |> Jason.decode!() - css = File.read!(Path.join([@outdir, "css", manifest["app.css"]["file"]])) + manifest = manifest() + css = File.read!(Path.join(@outdir, manifest["app.css"]["file"])) assert css =~ ".colocated-card" assert css =~ "oklch(98.7% .022 95.277)" end test "generates sourcemap", %{build_status: 0} do - manifest = @outdir |> Path.join("js/manifest.json") |> File.read!() |> Jason.decode!() + manifest = manifest() map_path = manifest["app.js"]["file"] <> ".map" - map = [@outdir, "js", map_path] |> Path.join() |> File.read!() |> Jason.decode!() + map = [@outdir, map_path] |> Path.join() |> File.read!() |> Jason.decode!() assert map["version"] == 3 end @@ -234,7 +232,11 @@ defmodule Volt.Integration.PhoenixExampleBuildTest do end defp js_path do - manifest = @outdir |> Path.join("js/manifest.json") |> File.read!() |> Jason.decode!() - Path.join([@outdir, "js", manifest["app.js"]["file"]]) + manifest = manifest() + Path.join(@outdir, manifest["app.js"]["file"]) + end + + defp manifest do + @outdir |> Path.join("manifest.json") |> File.read!() |> Jason.decode!() end end diff --git a/test/volt/integration/session_test.exs b/test/volt/integration/session_test.exs new file mode 100644 index 0000000..eb2a574 --- /dev/null +++ b/test/volt/integration/session_test.exs @@ -0,0 +1,91 @@ +defmodule Volt.Integration.SessionTest do + use ExUnit.Case, async: false + + @moduletag :integration + @moduletag :tmp_dir + + test "real filesystem edits update only their session", %{tmp_dir: root} do + sessions = + for label <- ["first", "second"] do + directory = Path.join(root, label) + File.mkdir_p!(directory) + source = Path.join(directory, "app.css") + dependency = Path.join(directory, "theme.css") + File.write!(source, "@import './theme.css';") + File.write!(dependency, ".#{label} { color: red }") + session = make_ref() + + supervisor = + start_supervised!( + {Volt.Dev.Session.Supervisor, + identity: session, + watcher: [ + root: directory, + session: session, + name: nil, + tailwind: true, + tailwind_css: source, + tailwind_sources: [], + tailwind_url: "/assets/site.css" + ]}, + id: session + ) + + config = %{ + Volt.DevServer.init( + root: directory, + watch: false, + session: session, + session_supervisor: supervisor + ) + | stylesheet_url: "/assets/site.css", + stylesheet_source: source + } + + {session, supervisor, dependency, config} + end + + [{first, _, dependency, config}, {second, _, _, other_config}] = sessions + parent = self() + + subscribers = + for session <- [first, second] do + spawn_link(fn -> + Registry.register(Volt.HMR.Registry, Volt.HMR.Channel.key(session), nil) + send(parent, {:ready, self()}) + + receive do + {:check, caller} -> + send(caller, {:mailbox, session, Process.info(self(), :messages)}) + + {:volt_hmr, type, payload} -> + send(parent, {:notification, session, type, payload}) + + receive do + :stop -> :ok + end + end + end) + end + + on_exit(fn -> Enum.each(subscribers, &Process.exit(&1, :kill)) end) + + for pid <- subscribers do + assert_receive {:ready, ^pid} + end + + before_other = Plug.Test.conn(:get, "/assets/site.css") |> Volt.DevServer.call(other_config) + File.write!(dependency, ".first { color: blue }") + assert_receive {:notification, ^first, :update, %{changes: [:style]}}, 10_000 + response = Plug.Test.conn(:get, "/assets/site.css") |> Volt.DevServer.call(config) + assert response.status == 200 + assert response.resp_body =~ "blue" + after_other = Plug.Test.conn(:get, "/assets/site.css") |> Volt.DevServer.call(other_config) + assert after_other.resp_body == before_other.resp_body + [first_subscriber, second_subscriber] = subscribers + send(second_subscriber, {:check, self()}) + assert_receive {:mailbox, ^second, {:messages, []}} + refute_received {:notification, ^second, _, _} + send(first_subscriber, :stop) + end +end diff --git a/test/volt/js/discovery_test.exs b/test/volt/js/discovery_test.exs new file mode 100644 index 0000000..3f0c0ba --- /dev/null +++ b/test/volt/js/discovery_test.exs @@ -0,0 +1,66 @@ +defmodule Volt.JS.DiscoveryTest do + use ExUnit.Case, async: false + + alias Volt.JS.Discovery + + setup do + tmp_dir = + Path.expand( + "volt-js-discovery-#{System.unique_integer([:positive])}", + System.tmp_dir!() + ) + + original_env = + Map.new([:format, :lint, :root, :sources, :ignore], fn key -> + {key, Application.fetch_env(:volt, key)} + end) + + File.mkdir_p!(Path.join(tmp_dir, "format")) + File.mkdir_p!(Path.join(tmp_dir, "lint")) + File.write!(Path.join(tmp_dir, "format/source.ts"), "const formatted = true") + File.write!(Path.join(tmp_dir, "lint/source.ts"), "const linted = true") + + on_exit(fn -> + Enum.each(original_env, fn {key, value} -> restore_env(key, value) end) + File.rm_rf!(tmp_dir) + end) + + %{tmp_dir: tmp_dir} + end + + test "discovers tool-specific source sets", %{tmp_dir: tmp_dir} do + Application.put_env(:volt, :format, + root: tmp_dir, + sources: ["format/**/*.ts"], + ignore: [] + ) + + Application.put_env(:volt, :lint, + root: tmp_dir, + sources: ["lint/**/*.ts"], + ignore: [] + ) + + assert Discovery.format_files() == [Path.join(tmp_dir, "format/source.ts")] + assert Discovery.files(tool: :lint) == [Path.join(tmp_dir, "lint/source.ts")] + end + + test "a bundle format on the same key holds no discovery options", %{tmp_dir: tmp_dir} do + Application.put_env(:volt, :format, :esm) + Application.put_env(:volt, :root, tmp_dir) + Application.put_env(:volt, :sources, ["format/**/*.ts"]) + Application.put_env(:volt, :ignore, []) + + Application.put_env(:volt, :lint, + root: tmp_dir, + sources: ["lint/**/*.ts"], + ignore: [] + ) + + assert Discovery.format_files() == [Path.join(tmp_dir, "format/source.ts")] + assert Discovery.files(tool: :lint) == [Path.join(tmp_dir, "lint/source.ts")] + end + + defp restore_env(key, :error), do: Application.delete_env(:volt, key) + defp restore_env(key, {:ok, value}), do: Application.put_env(:volt, key, value) +end diff --git a/test/volt/js/format_test.exs b/test/volt/js/format_test.exs index 2cb7794..bc68de7 100644 --- a/test/volt/js/format_test.exs +++ b/test/volt/js/format_test.exs @@ -25,4 +25,10 @@ defmodule Volt.JS.FormatTest do assert Format.load_config() == [semi: false, print_width: 100] end + + test "load_config/0 ignores a bundle format set on the same key" do + Application.put_env(:volt, :format, :esm) + + assert Format.load_config() == Format.load_json_config() + end end diff --git a/test/volt/js/helpers_test.exs b/test/volt/js/helpers_test.exs deleted file mode 100644 index 8eea207..0000000 --- a/test/volt/js/helpers_test.exs +++ /dev/null @@ -1,49 +0,0 @@ -defmodule Volt.JS.HelpersTest do - use ExUnit.Case, async: false - - alias Volt.JS.Helpers - - setup do - tmp_dir = - Path.expand( - "volt-js-helpers-#{System.unique_integer([:positive])}", - System.tmp_dir!() - ) - - original_format = Application.get_env(:volt, :format) - original_lint = Application.get_env(:volt, :lint) - - File.mkdir_p!(Path.join(tmp_dir, "format")) - File.mkdir_p!(Path.join(tmp_dir, "lint")) - File.write!(Path.join(tmp_dir, "format/source.ts"), "const formatted = true") - File.write!(Path.join(tmp_dir, "lint/source.ts"), "const linted = true") - - on_exit(fn -> - restore_env(:format, original_format) - restore_env(:lint, original_lint) - File.rm_rf!(tmp_dir) - end) - - %{tmp_dir: tmp_dir} - end - - test "discovers tool-specific source sets", %{tmp_dir: tmp_dir} do - Application.put_env(:volt, :format, - root: tmp_dir, - sources: ["format/**/*.ts"], - ignore: [] - ) - - Application.put_env(:volt, :lint, - root: tmp_dir, - sources: ["lint/**/*.ts"], - ignore: [] - ) - - assert Helpers.discover_format_files() == [Path.join(tmp_dir, "format/source.ts")] - assert Helpers.discover_files(tool: :lint) == [Path.join(tmp_dir, "lint/source.ts")] - end - - defp restore_env(key, nil), do: Application.delete_env(:volt, key) - defp restore_env(key, value), do: Application.put_env(:volt, key, value) -end diff --git a/test/volt/js/vendor_test.exs b/test/volt/js/vendor_test.exs index d6122c7..7d1ba7b 100644 --- a/test/volt/js/vendor_test.exs +++ b/test/volt/js/vendor_test.exs @@ -30,6 +30,26 @@ defmodule Volt.JS.VendorTest do :ok end + @tag :tmp_dir + test "distinct package roots cannot overwrite each other's vendor output", %{tmp_dir: root} do + contexts = + for name <- ["first", "second"] do + modules = Path.join([root, name, "node_modules"]) + package = Path.join(modules, "same-lib") + File.mkdir_p!(package) + File.write!(Path.join(package, "package.json"), ~s({"name":"same-lib","main":"index.js"})) + File.write!(Path.join(package, "index.js"), "export const value = '#{name}'") + assert {:ok, code} = Volt.JS.Vendor.bundle_on_demand("same-lib", modules) + {modules, code} + end + + [{first, first_code}, {second, second_code}] = contexts + assert first_code =~ "first" + assert second_code =~ "second" + assert {:ok, ^first_code} = Volt.JS.Vendor.read("same-lib", node_modules: first) + assert {:ok, ^second_code} = Volt.JS.Vendor.read("same-lib", node_modules: second) + end + describe "prebundle/1" do test "detects bare imports and bundles them" do {:ok, vendor_map} = @@ -47,7 +67,7 @@ defmodule Volt.JS.VendorTest do node_modules: @node_modules ) - assert File.regular?("_build/volt/vendor/fake-lib.js") + assert [_] = Path.wildcard("_build/volt/vendor/*/fake-lib.js") end test "does not scan into node_modules under the root" do @@ -126,8 +146,8 @@ defmodule Volt.JS.VendorTest do assert Map.has_key?(vendor_map, "editor-a") assert Map.has_key?(vendor_map, "editor-b") - {:ok, editor_a} = Volt.JS.Vendor.read("editor-a") - {:ok, editor_b} = Volt.JS.Vendor.read("editor-b") + {:ok, editor_a} = Volt.JS.Vendor.read("editor-a", node_modules: @node_modules) + {:ok, editor_b} = Volt.JS.Vendor.read("editor-b", node_modules: @node_modules) assert [chunk] = [editor_a, editor_b] @@ -136,7 +156,7 @@ defmodule Volt.JS.VendorTest do |> Enum.uniq() chunk_specifier = chunk |> String.trim_leading("./") |> String.trim_trailing(".js") - {:ok, shared_chunk} = Volt.JS.Vendor.read(chunk_specifier) + {:ok, shared_chunk} = Volt.JS.Vendor.read(chunk_specifier, node_modules: @node_modules) assert shared_chunk =~ "singleton" end @@ -185,7 +205,11 @@ defmodule Volt.JS.VendorTest do plugins: [BrokenSyntheticVendor] ) - assert {:ok, ^stable} = Volt.JS.Vendor.read("unstable-lib") + assert {:ok, ^stable} = + Volt.JS.Vendor.read("unstable-lib", + node_modules: @node_modules, + plugins: [StableSyntheticVendor] + ) end test "rebuilds cached synthetic entries when plugin prebundle source changes" do @@ -231,7 +255,7 @@ defmodule Volt.JS.VendorTest do node_modules: @node_modules ) - {:ok, code} = Volt.JS.Vendor.read("fake-lib") + {:ok, code} = Volt.JS.Vendor.read("fake-lib", node_modules: @node_modules) assert code =~ "greet" refute code =~ "module.exports" end @@ -252,7 +276,10 @@ defmodule Volt.JS.VendorTest do ) assert Map.has_key?(vendor_map, "react") - {:ok, react_proxy} = Volt.JS.Vendor.read("react") + + {:ok, react_proxy} = + Volt.JS.Vendor.read("react", node_modules: @node_modules, plugins: [Volt.Plugin.React]) + assert react_proxy =~ ~r/export \{[^}]*act/s end @@ -285,8 +312,14 @@ defmodule Volt.JS.VendorTest do assert Map.has_key?(vendor_map, "react") assert Map.has_key?(vendor_map, "react-using-dom") - {:ok, react_proxy} = Volt.JS.Vendor.read("react") - {:ok, react_using_dom} = Volt.JS.Vendor.read("react-using-dom") + {:ok, react_proxy} = + Volt.JS.Vendor.read("react", node_modules: @node_modules, plugins: [Volt.Plugin.React]) + + {:ok, react_using_dom} = + Volt.JS.Vendor.read("react-using-dom", + node_modules: @node_modules, + plugins: [Volt.Plugin.React] + ) assert [shared_chunk] = [react_proxy, react_using_dom] @@ -295,7 +328,12 @@ defmodule Volt.JS.VendorTest do |> Enum.uniq() chunk_specifier = shared_chunk |> String.trim_leading("./") |> String.trim_trailing(".js") - {:ok, shared_code} = Volt.JS.Vendor.read(chunk_specifier) + + {:ok, shared_code} = + Volt.JS.Vendor.read(chunk_specifier, + node_modules: @node_modules, + plugins: [Volt.Plugin.React] + ) assert react_using_dom =~ "flushSync" assert shared_code =~ "domSingleton" @@ -319,7 +357,13 @@ defmodule Volt.JS.VendorTest do ) assert Map.has_key?(vendor_map, "react-dom") - {:ok, react_dom_proxy} = Volt.JS.Vendor.read("react-dom") + + {:ok, react_dom_proxy} = + Volt.JS.Vendor.read("react-dom", + node_modules: @node_modules, + plugins: [Volt.Plugin.React] + ) + assert react_dom_proxy =~ ~r/export \{[^}]*flushSync/s end end @@ -368,7 +412,7 @@ defmodule Volt.JS.VendorTest do assert Map.has_key?(vendor_map, "cjs-lib") - {:ok, code} = Volt.JS.Vendor.read("cjs-lib") + {:ok, code} = Volt.JS.Vendor.read("cjs-lib", node_modules: @node_modules) assert code =~ "export" refute String.starts_with?(code, "\"use strict\";(function()") end @@ -414,7 +458,7 @@ defmodule Volt.JS.VendorTest do node_modules: @node_modules ) - {:ok, code} = Volt.JS.Vendor.read("conditional-lib") + {:ok, code} = Volt.JS.Vendor.read("conditional-lib", node_modules: @node_modules) assert code =~ "development" end @@ -457,7 +501,7 @@ defmodule Volt.JS.VendorTest do node_modules: @node_modules ) - {:ok, code} = Volt.JS.Vendor.read("dep-b") + {:ok, code} = Volt.JS.Vendor.read("dep-b", node_modules: @node_modules) assert code =~ "require_dep_a" assert code =~ "exports.b = require_dep_a().a + 1" end @@ -494,7 +538,10 @@ defmodule Volt.JS.VendorTest do ) assert Map.has_key?(vendor_map, "hex-lib") - {:ok, code} = Volt.JS.Vendor.read("hex-lib") + + {:ok, code} = + Volt.JS.Vendor.read("hex-lib", node_modules: @node_modules, resolve_dirs: [@deps_dir]) + assert code =~ "from deps" end @@ -535,7 +582,13 @@ defmodule Volt.JS.VendorTest do ) assert Map.has_key?(vendor_map, "phoenix-colocated/my_app") - {:ok, code} = Volt.JS.Vendor.read("phoenix-colocated/my_app") + + {:ok, code} = + Volt.JS.Vendor.read("phoenix-colocated/my_app", + node_modules: @node_modules, + resolve_dirs: [@deps_dir] + ) + assert code =~ "MyAppWeb.DemoLive.Sortable" assert code =~ "colocated hook mounted" end @@ -549,7 +602,7 @@ defmodule Volt.JS.VendorTest do test "caches the result for subsequent read/1 calls" do {:ok, _} = Volt.JS.Vendor.bundle_on_demand("fake-lib", @node_modules) - {:ok, code} = Volt.JS.Vendor.read("fake-lib") + {:ok, code} = Volt.JS.Vendor.read("fake-lib", node_modules: @node_modules) assert code =~ "greet" end @@ -583,12 +636,13 @@ defmodule Volt.JS.VendorTest do node_modules: @node_modules ) - {:ok, code} = Volt.JS.Vendor.read("fake-lib") + {:ok, code} = Volt.JS.Vendor.read("fake-lib", node_modules: @node_modules) assert code =~ "greet" end test "returns error for missing vendor" do - assert {:error, :not_found} = Volt.JS.Vendor.read("nonexistent") + assert {:error, :not_found} = + Volt.JS.Vendor.read("nonexistent", node_modules: @node_modules) end end diff --git a/test/volt/plugin/vue_test.exs b/test/volt/plugin/vue_test.exs new file mode 100644 index 0000000..1577d9c --- /dev/null +++ b/test/volt/plugin/vue_test.exs @@ -0,0 +1,81 @@ +defmodule Volt.Plugin.VueTest do + use ExUnit.Case, async: true + + test "template-only SFCs expose a default component" do + assert {:ok, result} = + Volt.Plugin.Vue.compile( + "/fixtures/Only.vue", + "", + [] + ) + + assert result.code =~ "export default { render }" + end + + test "script-bearing scoped components receive their CSS scope on the default export" do + for script <- [ + "", + "" + ] do + source = + script <> + "" + + assert {:ok, result} = Volt.Plugin.Vue.compile("/fixtures/Script.vue", source, []) + scope = "data-v-" <> Vize.SFC.scope_id("/fixtures/Script.vue") + assert result.code =~ "__scopeId = \"#{scope}\"" + assert result.css =~ scope + assert {:ok, _} = OXC.parse(result.code, "component.js") + assert is_nil(result.sourcemap) + end + end + + @tag :tmp_dir + test "scope attachment does not capture module bindings and preserves component identity", %{ + tmp_dir: tmp + } do + file = "/fixtures/Shadow.vue" + + source = """ + + + """ + + assert {:ok, result} = Volt.Plugin.Vue.compile(file, source, []) + File.write!(Path.join(tmp, "scoped.js"), result.code) + + File.write!(Path.join(tmp, "assertions.js"), """ + import scoped, { Object, component } from "./scoped.js"; + globalThis.observed = { + scope: scoped.__scopeId, + same: scoped === component, + value: scoped.value, + untouched: !Object.__scopeId + }; + """) + + runtime = start_supervised!({QuickBEAM, script: Path.join(tmp, "assertions.js")}) + + scope = "data-v-" <> Vize.SFC.scope_id(file) + + assert {:ok, %{"scope" => ^scope, "same" => true, "value" => 42, "untouched" => true}} = + QuickBEAM.eval(runtime, "globalThis.observed") + end + + test "template-only scoped CSS and component use the same scope identity" do + file = "/fixtures/Scoped.vue" + + source = + "" + + assert {:ok, result} = Volt.Plugin.Vue.compile(file, source, []) + scope = "data-v-" <> Vize.SFC.scope_id(file) + assert result.code =~ scope + assert result.css =~ scope + assert result.code =~ "__scopeId" + end +end diff --git a/test/volt/preload_test.exs b/test/volt/preload_test.exs index 34c3d87..3065ece 100644 --- a/test/volt/preload_test.exs +++ b/test/volt/preload_test.exs @@ -37,6 +37,22 @@ defmodule Volt.PreloadTest do refute result =~ "lazy-fedcba.js" end + test "resolves cyclic and nested import keys to emitted paths" do + manifest = %{ + "app.js" => %{ + "file" => "js/app-hash.js", + "imports" => ["shared", "missing"], + "dynamicImports" => ["lazy"] + }, + "shared" => %{"file" => "js/shared-hash.js", "imports" => ["vendor"]}, + "vendor" => %{"file" => "js/vendor-hash.js", "imports" => ["shared"]}, + "lazy" => %{"file" => "js/lazy-hash.js"} + } + + assert Volt.Preload.tags(manifest, entry: "app.js", prefix: "https://cdn.example/assets") == + ~s(\n) + end + test "joins prefix with URI semantics" do manifest = %{"app.js" => "app-abc123.js"} diff --git a/test/volt/public_dir_test.exs b/test/volt/public_dir_test.exs new file mode 100644 index 0000000..7a4d92e --- /dev/null +++ b/test/volt/public_dir_test.exs @@ -0,0 +1,39 @@ +defmodule Volt.PublicDirTest do + use ExUnit.Case, async: true + + alias Volt.Builder.{Artifact, Plan} + @moduletag :tmp_dir + + test "explicit public root prefixes nested compiled outputs", %{tmp_dir: root} do + public = Path.join(root, "public") + File.mkdir_p!(public) + File.write!(Path.join(public, "robots.txt"), "public") + {:ok, plan} = Plan.new([%Artifact{file: "app.js", content: "script"}]) + output = Path.join(root, "dist/assets") + static = Path.join(root, "dist") + assert {:ok, ^static, combined} = Volt.PublicDir.prepare_output(plan, output, public, static) + assert Enum.map(combined.artifacts, & &1.file) == ["assets/app.js", "robots.txt"] + refute File.exists?(static) + end + + test "can represent the lower-level parent-directory contract", %{tmp_dir: root} do + {:ok, plan} = Plan.new([%Artifact{file: "app.js", content: "script"}]) + + assert {:ok, ^root, combined} = + Volt.PublicDir.prepare_output(plan, Path.join(root, "assets"), nil, root) + + assert [%Artifact{file: "assets/app.js"}] = combined.artifacts + end + + test "rejects unrelated public destinations", %{tmp_dir: root} do + {:ok, plan} = Plan.new([]) + + assert {:error, {:invalid_publication_root, _, _}} = + Volt.PublicDir.prepare_output( + plan, + Path.join(root, "assets"), + nil, + Path.join(root, "other") + ) + end +end diff --git a/test/volt/tailwind/artifact_test.exs b/test/volt/tailwind/artifact_test.exs new file mode 100644 index 0000000..0e0a593 --- /dev/null +++ b/test/volt/tailwind/artifact_test.exs @@ -0,0 +1,16 @@ +defmodule Volt.Tailwind.ArtifactTest do + use ExUnit.Case, async: true + + @moduletag :tmp_dir + + test "replaces the stylesheet without leaving temporary files", %{tmp_dir: tmp} do + assert :ok = Volt.Tailwind.Artifact.write(tmp, "site", "first") + assert :ok = Volt.Tailwind.Artifact.write(tmp, "site", "second") + + assert File.read!(Path.join(tmp, "site.css")) == "second" + assert File.ls!(tmp) == ["site.css"] + end + + test "accepts disabled output", + do: assert(:ok = Volt.Tailwind.Artifact.write(nil, "site", "css")) +end diff --git a/test/volt/tailwind/build_test.exs b/test/volt/tailwind/build_test.exs new file mode 100644 index 0000000..a76925f --- /dev/null +++ b/test/volt/tailwind/build_test.exs @@ -0,0 +1,124 @@ +defmodule Volt.Tailwind.BuildTest do + use ExUnit.Case, async: false + + @moduletag :tmp_dir + + test "nested stylesheet assets retain their origins", %{tmp_dir: root} do + for directory <- ["a", "b"] do + File.mkdir_p!(Path.join(root, directory)) + File.write!(Path.join([root, directory, "logo.svg"]), "#{directory}") + + File.write!( + Path.join([root, directory, "theme.css"]), + ".#{directory} { background: url('./logo.svg?v=1#mark') }" + ) + end + + File.write!(Path.join(root, "a/font.woff2"), <<0, 1, 2, 3>>) + + File.write!( + Path.join(root, "a/font.css"), + "@font-face { font-family: Test; src: url('./font.woff2') }" + ) + + css = Path.join(root, "style.css") + + File.write!( + css, + "@import 'tailwindcss' source(none); @import './a/theme.css'; @import './b/theme.css'; @import './a/font.css';" + ) + + config = Volt.Config.Tailwind.new(css: css) + + assert {:ok, result, plan} = + Volt.Tailwind.Build.prepare(config, + outdir: Path.join(root, "output"), + root: root, + hash: false, + minify: false, + asset_url_prefix: "https://cdn.example/assets" + ) + + code = Enum.find(plan.artifacts, &(&1.file == "style.css")).content + + for source <- ["a/logo.svg", "b/logo.svg", "a/font.woff2"] do + file = Map.fetch!(result.manifest, source).file + assert code =~ "https://cdn.example/assets/#{file}" + assert Enum.any?(plan.artifacts, &(&1.file == file)) + end + + refute result.manifest["a/logo.svg"].file == result.manifest["b/logo.svg"].file + assert code =~ "?v=1#mark" + end + + test "compiler sources and exclusions control production scanning", %{tmp_dir: root} do + File.mkdir_p!(Path.join(root, "pages")) + File.write!(Path.join(root, "pages/index.html"), ~s(
)) + File.write!(Path.join(root, "pages/private.html"), ~s()) + File.write!(Path.join(root, "outside.html"), ~s(
)) + css = Path.join(root, "style.css") + + File.write!( + css, + "@import 'tailwindcss' source(none); @source './pages/*.html'; @source not './pages/private.html';" + ) + + config = Volt.Config.Tailwind.new(css: css) + opts = [outdir: Path.join(root, "output"), root: root, hash: false, minify: false] + assert {:ok, _, plan} = Volt.Tailwind.Build.prepare(config, opts) + code = Enum.find(plan.artifacts, &(&1.file == "style.css")).content + assert code =~ ".flex" + refute code =~ ".hidden" + refute code =~ ".grid" + + File.write!(css, "@import 'tailwindcss';") + assert {:ok, _, automatic} = Volt.Tailwind.Build.prepare(config, opts) + code = Enum.find(automatic.artifacts, &(&1.file == "style.css")).content + assert code =~ ".grid" + end + + test "production preparations scan fresh without registering workers", %{tmp_dir: root} do + css = Path.join(root, "style.css") + page = Path.join(root, "page.html") + File.write!(css, "@import 'tailwindcss' source(none);") + File.write!(page, ~s()) + config = Volt.Config.Tailwind.new(css: css, sources: [%{base: root, pattern: "*.html"}]) + opts = [outdir: Path.join(root, "output"), hash: false, minify: false] + workers = Registry.count(Volt.Tailwind.Registry) + + assert {:ok, _, first} = Volt.Tailwind.Build.prepare(config, opts) + assert Enum.any?(first.artifacts, &String.contains?(&1.content, ".hidden")) + File.write!(page, ~s(
)) + assert {:ok, _, second} = Volt.Tailwind.Build.prepare(config, opts) + refute Enum.any?(second.artifacts, &String.contains?(&1.content, ".hidden")) + assert Registry.count(Volt.Tailwind.Registry) == workers + end + + test "prepares CSS and referenced assets without creating output", %{tmp_dir: root} do + css = Path.join(root, "style.css") + + File.write!( + css, + "@import 'tailwindcss' source(none); .logo { background: url('./logo.svg') }" + ) + + File.write!(Path.join(root, "logo.svg"), "") + output = Path.join(root, "output") + config = Volt.Config.Tailwind.new(css: css, name: "site") + + assert {:ok, result, plan} = + Volt.Tailwind.Build.prepare(config, + key: {:preparation_test, root}, + outdir: output, + root: root, + hash: false, + minify: false, + asset_url_prefix: "/static" + ) + + assert result.manifest["site.css"].file == "site.css" + assert Enum.any?(plan.artifacts, &(&1.file == "site.css")) + assert Enum.any?(plan.artifacts, &(&1.content == "")) + refute File.exists?(output) + end +end diff --git a/test/volt/tailwind/runtime_test.exs b/test/volt/tailwind/runtime_test.exs new file mode 100644 index 0000000..b673367 --- /dev/null +++ b/test/volt/tailwind/runtime_test.exs @@ -0,0 +1,168 @@ +defmodule Volt.Tailwind.RuntimeTest do + use ExUnit.Case, async: false + + @tag :tmp_dir + test "reports imported stylesheet and transitive plugin dependencies", %{tmp_dir: root} do + File.write!(Path.join(root, "theme.css"), "@source './pages/*.html';") + File.write!(Path.join(root, "helper.cjs"), "module.exports = {color: 'red'}") + + File.write!( + Path.join(root, "plugin.cjs"), + "const value = require('./helper.cjs'); module.exports = ({addUtilities}) => addUtilities({'.custom': value})" + ) + + session = make_ref() + runtime = Volt.Tailwind.Supervisor.runtime({:session, session, :css}) + on_exit(fn -> Volt.Tailwind.Supervisor.release_runtime(session) end) + + assert {:ok, metadata} = + Volt.Tailwind.Runtime.compile_metadata( + "@import './theme.css'; @plugin './plugin.cjs'; @tailwind utilities source(none);", + ["custom"], + root, + runtime + ) + + assert metadata.root == :none + assert [%Oxide.Source{pattern: "./pages/*.html"}] = metadata.sources + + for file <- ["theme.css", "plugin.cjs", "helper.cjs"] do + assert Path.join(root, file) in metadata.dependencies + end + + assert metadata.code =~ ".custom" + end + + for operation <- [:call, :compile_metadata] do + @tag operation: operation + test "compiler death during #{operation} returns an error and permits recovery", %{ + operation: operation + } do + session = make_ref() + wrapper = Volt.Tailwind.Supervisor.runtime({:session, session, :css}) + on_exit(fn -> Volt.Tailwind.Supervisor.release_runtime(session) end) + parent = self() + + {:ok, compiler} = + QuickBEAM.start( + handlers: %{ + "blocked" => fn [] -> + send(parent, {:compiler_blocked, self()}) + + receive do + :release -> :ok + end + end + } + ) + + # Install a controlled runtime in the wrapper to exercise the real call/exit path. + :sys.replace_state(wrapper, fn _ -> %Volt.JS.Runtime{pid: compiler} end) + Process.unlink(compiler) + on_exit(fn -> if Process.alive?(compiler), do: QuickBEAM.stop(compiler) end) + + {:ok, _} = + QuickBEAM.eval(compiler, "globalThis.compileTailwindCss = () => Beam.call('blocked')") + + task = + Task.async(fn -> + apply(Volt.Tailwind.Runtime, operation, ["", [], File.cwd!(), wrapper]) + end) + + assert_receive {:compiler_blocked, handler} + Process.exit(compiler, :kill) + send(handler, :release) + assert {:error, {:compiler_exit, _}} = Task.await(task) + assert Process.alive?(wrapper) + + assert {:ok, css} = + Volt.Tailwind.Runtime.call("@tailwind utilities;", ["flex"], File.cwd!(), wrapper) + + assert css =~ ".flex" + end + end + + test "retained contexts reuse the compiler and reject a different runtime generation" do + session = make_ref() + runtime = Volt.Tailwind.Supervisor.runtime({:session, session, :css}) + on_exit(fn -> Volt.Tailwind.Supervisor.release_runtime(session) end) + + assert {:ok, context, metadata} = + Volt.Tailwind.Runtime.prepare_context("@tailwind utilities;", File.cwd!(), runtime) + + assert metadata.code == "" + assert {:ok, first} = Volt.Tailwind.Runtime.build_context(context, ["flex"], runtime) + assert first =~ ".flex" + assert {:ok, second} = Volt.Tailwind.Runtime.build_context(context, ["grid"], runtime) + assert second =~ ".flex" + assert second =~ ".grid" + monitor = Process.monitor(context.runtime) + Process.exit(context.runtime, :kill) + assert_receive {:DOWN, ^monitor, :process, _, :killed} + + assert {:error, :stale_compiler_context} = + Volt.Tailwind.Runtime.build_context(context, ["hidden"], runtime) + + assert {:ok, replacement, _} = + Volt.Tailwind.Runtime.prepare_context("@tailwind utilities;", File.cwd!(), runtime) + + refute replacement.runtime == context.runtime + assert {:ok, fresh} = Volt.Tailwind.Runtime.build_context(replacement, ["hidden"], runtime) + assert fresh =~ ".hidden" + refute fresh =~ ".flex" + assert :ok = Volt.Tailwind.Runtime.release_context(replacement, runtime) + assert {:error, _} = Volt.Tailwind.Runtime.build_context(replacement, [], runtime) + end + + test "session runtimes isolate compiler death and recover on the next call" do + first = make_ref() + second = make_ref() + a = Volt.Tailwind.Supervisor.runtime({:session, first, :css}) + b = Volt.Tailwind.Supervisor.runtime({:session, second, :css}) + + on_exit(fn -> + Volt.Tailwind.Supervisor.release_runtime(first) + Volt.Tailwind.Supervisor.release_runtime(second) + end) + + refute a == b + assert {:ok, _} = Volt.Tailwind.Runtime.call("@tailwind utilities;", ["flex"], File.cwd!(), a) + assert {:ok, _} = Volt.Tailwind.Runtime.call("@tailwind utilities;", ["grid"], File.cwd!(), b) + compiler = :sys.get_state(a).pid + monitor = Process.monitor(compiler) + Process.exit(compiler, :kill) + assert_receive {:DOWN, ^monitor, :process, ^compiler, :killed} + + assert {:ok, css} = + Volt.Tailwind.Runtime.call("@tailwind utilities;", ["hidden"], File.cwd!(), a) + + assert css =~ ".hidden" + assert Process.alive?(b) + refute :sys.get_state(a).pid == compiler + assert :ok = Volt.Tailwind.Supervisor.release_runtime(first) + refute Process.alive?(a) + assert Process.alive?(b) + end + + test "one-shot compilation releases its linked runtime on success and compiler error" do + {:links, before_links} = Process.info(self(), :links) + before_links = MapSet.new(before_links) + + assert {:ok, css} = + Volt.Tailwind.Runtime.compile_once("@tailwind utilities;", ["flex"], File.cwd!()) + + assert css =~ ".flex" + {:links, after_success} = Process.info(self(), :links) + assert MapSet.new(after_success) == before_links + + assert {:error, _} = + Volt.Tailwind.Runtime.compile_once( + "@import './does-not-exist.css';", + [], + File.cwd!() + ) + + {:links, after_failure} = Process.info(self(), :links) + assert MapSet.new(after_failure) == before_links + end +end diff --git a/test/volt/tailwind/worker_test.exs b/test/volt/tailwind/worker_test.exs new file mode 100644 index 0000000..7d0839e --- /dev/null +++ b/test/volt/tailwind/worker_test.exs @@ -0,0 +1,140 @@ +defmodule Volt.Tailwind.WorkerTest do + use ExUnit.Case, async: false + + @moduletag :tmp_dir + + test "changed source directives reconstruct scanning without retaining old discovered roots", %{ + tmp_dir: tmp + } do + key = {:test, make_ref()} + on_exit(fn -> Volt.Tailwind.Supervisor.release(key) end) + File.mkdir_p!(Path.join(tmp, "first")) + File.mkdir_p!(Path.join(tmp, "second")) + File.write!(Path.join(tmp, "first/page.html"), "
") + File.write!(Path.join(tmp, "second/page.html"), "
") + first_css = "@tailwind utilities source(none); @source './first/*.html';" + second_css = "@tailwind utilities source(none); @source './second/*.html';" + + assert {:ok, first} = + Volt.Tailwind.build(key: key, css: first_css, css_base: tmp, sources: []) + + assert first =~ ".flex" + assert {:ok, second} = Volt.Tailwind.rebuild([], key: key, css: second_css) + assert second =~ ".grid" + refute second =~ ".flex" + assert {:ok, rebuilt} = Volt.Tailwind.build(key: key, css: second_css, css_base: tmp) + refute rebuilt =~ ".flex" + end + + test "recovers a dead context without new candidates and releases it on shutdown", %{ + tmp_dir: tmp + } do + key = {:test, make_ref()} + on_exit(fn -> Volt.Tailwind.Supervisor.release(key) end) + File.write!(Path.join(tmp, "page.html"), "
") + + assert {:ok, first} = + Volt.Tailwind.build( + key: key, + css: "@tailwind utilities;", + sources: [%{base: tmp, pattern: "*.html"}] + ) + + [{worker, _}] = Registry.lookup(Volt.Tailwind.Registry, key) + old = :sys.get_state(worker).context + monitor = Process.monitor(old.runtime) + Process.exit(old.runtime, :kill) + assert_receive {:DOWN, ^monitor, :process, _, :killed} + assert :unchanged = Volt.Tailwind.rebuild([], key: key) + state = :sys.get_state(worker) + refute state.context.runtime == old.runtime + assert {:ok, ^first} = Volt.Tailwind.Worker.stylesheet(worker) + assert :ok = Volt.Tailwind.Supervisor.release(key) + assert {:error, _} = Volt.Tailwind.Runtime.build_context(state.context, [], state.runtime) + assert GenServer.whereis(state.runtime) + end + + test "candidate-only rebuild retains custom CSS and base directory", %{tmp_dir: tmp} do + key = {:test, make_ref()} + on_exit(fn -> Volt.Tailwind.Supervisor.release(key) end) + File.write!(Path.join(tmp, "theme.css"), ".retained { color: red }") + File.write!(Path.join(tmp, "page.html"), ~s(

)) + + assert {:ok, first} = + Volt.Tailwind.build( + key: key, + css: "@import './theme.css'; @tailwind utilities;", + css_base: tmp, + minify: true, + sources: [%{base: tmp, pattern: "*.html"}] + ) + + assert first =~ ".retained" + File.write!(Path.join(tmp, "page.html"), ~s(

)) + assert {:ok, rebuilt} = Volt.Tailwind.rebuild([Path.join(tmp, "page.html")], key: key) + assert rebuilt =~ ".retained" + assert rebuilt =~ ".grid" + assert [{pid, _}] = Registry.lookup(Volt.Tailwind.Registry, key) + assert :sys.get_state(pid).compilation.minify + end + + test "isolates scanner state by build context", %{tmp_dir: tmp} do + first = Path.join(tmp, "first") + second = Path.join(tmp, "second") + File.mkdir_p!(first) + File.mkdir_p!(second) + File.write!(Path.join(first, "page.html"), ~S|

|) + File.write!(Path.join(second, "page.html"), ~S|

|) + + assert {:ok, first_css} = + Volt.Tailwind.build( + key: {:test, make_ref()}, + sources: [%{base: first, pattern: "**/*.html"}] + ) + + assert {:ok, second_css} = + Volt.Tailwind.build( + key: {:test, make_ref()}, + sources: [%{base: second, pattern: "**/*.html"}] + ) + + assert first_css =~ ".grid" + refute first_css =~ ".flex" + assert second_css =~ ".flex" + refute second_css =~ ".grid" + end + + test "root workers share one compiler runtime" do + first_key = {:test, make_ref()} + second_key = {:test, make_ref()} + + assert {:ok, _css} = Volt.Tailwind.build(key: first_key, sources: []) + runtime_pid = Process.whereis(Volt.Tailwind.Runtime) + assert is_pid(runtime_pid) + + assert {:ok, _css} = Volt.Tailwind.build(key: second_key, sources: []) + assert Process.whereis(Volt.Tailwind.Runtime) == runtime_pid + assert Process.alive?(runtime_pid) + + assert [{first_worker, _}] = Registry.lookup(Volt.Tailwind.Registry, first_key) + assert [{second_worker, _}] = Registry.lookup(Volt.Tailwind.Registry, second_key) + assert first_worker != second_worker + end + + test "rebuild returns unchanged when generated CSS is identical", %{tmp_dir: tmp} do + key = {:test, make_ref()} + File.write!(Path.join(tmp, "page.html"), ~S|

|) + + assert {:ok, _css} = + Volt.Tailwind.build( + key: key, + sources: [%{base: tmp, pattern: "**/*.html"}] + ) + + assert :unchanged = + Volt.Tailwind.rebuild( + [%{content: ~S|

|, extension: "html"}], + key: key + ) + end +end diff --git a/test/volt/test/browser_runner_test.exs b/test/volt/test/browser_runner_test.exs index 069e8d0..bd7eb7f 100644 --- a/test/volt/test/browser_runner_test.exs +++ b/test/volt/test/browser_runner_test.exs @@ -46,7 +46,9 @@ defmodule Volt.Test.BrowserRunnerTest do test('sees browser globals', () => { document.body.innerHTML = '' - expect(window.location.href).toMatch('about:blank') + const pageURL = new URL(window.location.href) + expect(pageURL.protocol).toBe('file:') + expect(pageURL.pathname.endsWith('/index.html')).toBe(true) expect(document.querySelector('#ok')?.textContent).toBe('OK') }) """) diff --git a/test/volt/watcher/path_test.exs b/test/volt/watcher/path_test.exs index e68e048..958bbf4 100644 --- a/test/volt/watcher/path_test.exs +++ b/test/volt/watcher/path_test.exs @@ -12,6 +12,16 @@ defmodule Volt.Watcher.PathTest do Path.join(root, "app.js") end + @tag :tmp_dir + test "normalizes an aliased event for the watched root itself", %{tmp_dir: tmp_dir} do + root = Path.join(tmp_dir, "root") + alias_path = Path.join(tmp_dir, "alias") + File.mkdir_p!(root) + File.ln_s!(root, alias_path) + + assert Volt.Watcher.Path.normalize_from_roots(alias_path, [root]) == root + end + test "preserves paths that do not belong to a configured root" do assert Volt.Watcher.Path.normalize_from_roots("/outside/app.js", ["/site/assets"]) == "/outside/app.js" diff --git a/test/volt/watcher_test.exs b/test/volt/watcher_test.exs index fdd4498..ed8dbbf 100644 --- a/test/volt/watcher_test.exs +++ b/test/volt/watcher_test.exs @@ -12,6 +12,302 @@ defmodule Volt.WatcherTest do {:ok, watch_dir: watch_dir} end + @tag :tmp_dir + test "installing a missing package repairs session compilation", %{tmp_dir: root} do + assets = Path.join(root, "assets") + modules = Path.join(root, "node_modules") + File.mkdir_p!(assets) + File.mkdir_p!(modules) + css = Path.join(assets, "app.css") + File.write!(css, "@plugin 'missing-recovery-fixture';") + + supervisor = + start_supervised!( + {Volt.Dev.Session.Supervisor, + identity: make_ref(), + watcher: [ + root: assets, + name: nil, + tailwind: true, + tailwind_css: css, + tailwind_sources: [] + ]} + ) + + {_, watcher, _, _} = + Enum.find(Supervisor.which_children(supervisor), fn {id, _, _, _} -> + id == Volt.Dev.Session.Watcher + end) + + state = :sys.get_state(watcher) + package = Path.join(modules, "missing-recovery-fixture") + metadata = Path.join(package, "package.json") + assert metadata in state.config.tailwind_dependencies + File.mkdir_p!(package) + File.write!(metadata, ~s({"name":"missing-recovery-fixture","main":"index.js"})) + + File.write!( + Path.join(package, "index.js"), + "module.exports = ({addBase}) => addBase({'.installed': {color: 'red'}})" + ) + + assert {:noreply, queued} = + Volt.Watcher.handle_info({:file_event, self(), {metadata, [:created]}}, state) + + assert is_reference(queued.tailwind_timer) + Process.cancel_timer(queued.tailwind_timer) + assert {:noreply, _} = Volt.Watcher.handle_info(:tailwind_rebuild, queued) + assert {:ok, code} = Volt.Tailwind.Worker.stylesheet(state.tables.stylesheet_worker) + assert code =~ ".installed" + end + + @tag :tmp_dir + test "missing plugin helper creation repairs session compilation", %{tmp_dir: root} do + assets = Path.join(root, "assets") + external = Path.join(root, "plugins") + File.mkdir_p!(assets) + File.mkdir_p!(external) + plugin = Path.join(external, "theme.cjs") + helper = Path.join(external, "helper.cjs") + + File.write!( + plugin, + "const values = require('./helper.cjs'); module.exports = ({addBase}) => addBase(values)" + ) + + css = Path.join(assets, "app.css") + File.write!(css, "@plugin '../plugins/theme.cjs';") + + supervisor = + start_supervised!( + {Volt.Dev.Session.Supervisor, + identity: make_ref(), + watcher: [ + root: assets, + name: nil, + tailwind: true, + tailwind_css: css, + tailwind_sources: [] + ]} + ) + + {_, watcher, _, _} = + Enum.find(Supervisor.which_children(supervisor), fn {id, _, _, _} -> + id == Volt.Dev.Session.Watcher + end) + + state = :sys.get_state(watcher) + assert helper in state.config.tailwind_dependencies + assert external in state.tailwind_dirs + File.write!(helper, "module.exports = {'.repaired': {color: 'red'}}") + + assert {:noreply, queued} = + Volt.Watcher.handle_info({:file_event, self(), {helper, [:created]}}, state) + + Process.cancel_timer(queued.tailwind_timer) + assert queued.tailwind_full? + assert {:noreply, _} = Volt.Watcher.handle_info(:tailwind_rebuild, queued) + assert {:ok, code} = Volt.Tailwind.Worker.stylesheet(state.tables.stylesheet_worker) + assert code =~ ".repaired" + end + + @tag :tmp_dir + test "missing external CSS creation repairs failed initial compilation", %{tmp_dir: root} do + assets = Path.join(root, "assets") + File.mkdir_p!(assets) + css = Path.join(assets, "app.css") + missing = Path.join(root, "external/theme.css") + File.write!(css, "@import '../external/theme.css';") + + supervisor = + start_supervised!( + {Volt.Dev.Session.Supervisor, + identity: make_ref(), + watcher: [ + root: assets, + name: nil, + tailwind: true, + tailwind_css: css, + tailwind_sources: [] + ]} + ) + + {_, watcher, _, _} = + Enum.find(Supervisor.which_children(supervisor), fn {id, _, _, _} -> + id == Volt.Dev.Session.Watcher + end) + + state = :sys.get_state(watcher) + assert missing in state.config.tailwind_dependencies + assert root in state.tailwind_dirs + File.mkdir_p!(Path.dirname(missing)) + File.write!(missing, ".repaired { color: red }") + + assert {:noreply, queued} = + Volt.Watcher.handle_info({:file_event, self(), {missing, [:created]}}, state) + + Process.cancel_timer(queued.tailwind_timer) + assert queued.tailwind_full? + assert {:noreply, _} = Volt.Watcher.handle_info(:tailwind_rebuild, queued) + assert {:ok, code} = Volt.Tailwind.Worker.stylesheet(state.tables.stylesheet_worker) + assert code =~ ".repaired" + end + + @tag :tmp_dir + test "session observes compiler-discovered external stylesheet dependencies", %{tmp_dir: root} do + assets = Path.join(root, "assets") + external = Path.join(root, "external") + File.mkdir_p!(assets) + File.mkdir_p!(external) + imported = Path.join(external, "theme.css") + File.write!(imported, ".external { color: red }") + css = Path.join(assets, "app.css") + + File.write!( + css, + "@import '../external/theme.css'; @source '../external/*.html'; @tailwind utilities source(none);" + ) + + supervisor = + start_supervised!( + {Volt.Dev.Session.Supervisor, + identity: make_ref(), + watcher: [ + root: assets, + name: nil, + tailwind: true, + tailwind_css: css, + tailwind_sources: [] + ]} + ) + + {_, watcher, _, _} = + Enum.find(Supervisor.which_children(supervisor), fn {id, _, _, _} -> + id == Volt.Dev.Session.Watcher + end) + + state = :sys.get_state(watcher) + assert imported in state.config.tailwind_dependencies + assert external in state.tailwind_dirs + File.write!(imported, ".external { color: blue }") + + assert {:noreply, queued} = + Volt.Watcher.handle_info({:file_event, self(), {imported, [:modified]}}, state) + + assert queued.tailwind_full? + Process.cancel_timer(queued.tailwind_timer) + assert {:noreply, rebuilt} = Volt.Watcher.handle_info(:tailwind_rebuild, queued) + assert {:ok, updated} = Volt.Tailwind.Worker.stylesheet(state.tables.stylesheet_worker) + assert updated =~ "blue" + external_watcher = Map.fetch!(rebuilt.discovered_watches, external) + File.write!(css, "@tailwind utilities source(none);") + + assert {:noreply, pruned} = + Volt.Watcher.handle_info(:tailwind_rebuild, %{rebuilt | tailwind_full?: true}) + + refute external in pruned.tailwind_dirs + refute imported in pruned.config.tailwind_dependencies + refute Process.alive?(external_watcher) + assert assets in pruned.tailwind_dirs + end + + @tag :tmp_dir + test "document reload waits for successful CSS and remains pending on failure", %{tmp_dir: root} do + File.mkdir_p!(Path.join(root, "pages")) + page = Path.join(root, "pages/index.astral") + css = Path.join(root, "app.css") + File.write!(page, "
") + File.write!(css, "@tailwind utilities;") + + watcher = + start_supervised!( + {Volt.Watcher, + root: root, + name: nil, + tailwind: true, + tailwind_css: css, + reload_dirs: [Path.dirname(page)], + tailwind_sources: [%{base: root, pattern: "pages/*.astral"}]} + ) + + Registry.register(Volt.HMR.Registry, :clients, nil) + state = :sys.get_state(watcher) + + assert {:noreply, queued} = + Volt.Watcher.handle_info({:file_event, self(), {page, [:modified]}}, state) + + Process.cancel_timer(queued.tailwind_timer) + refute_received {:volt_hmr, _, _} + assert queued.pending_reloads == [page] + # Feed state directly to avoid filesystem timing; a missing input forces the rebuild error. + failed = %{ + queued + | tailwind_full?: true, + config: Map.put(queued.config, :tailwind_css, Path.join(root, "missing.css")) + } + + assert {:noreply, pending} = Volt.Watcher.handle_info(:tailwind_rebuild, failed) + assert pending.pending_reloads == [page] + assert pending.tailwind_full? + assert_receive {:volt_hmr, :error, _} + refute_received {:volt_hmr, :update, _} + File.write!(css, "@tailwind utilities; .changed { color: red }") + + assert {:noreply, recovered} = + Volt.Watcher.handle_info( + {:file_event, self(), {page, [:modified]}}, + %{pending | config: state.config} + ) + + Process.cancel_timer(recovered.tailwind_timer) + assert recovered.tailwind_full? + assert {:noreply, completed} = Volt.Watcher.handle_info(:tailwind_rebuild, recovered) + assert completed.pending_reloads == [] + assert_receive {:volt_hmr, :update, %{changes: [:style]}} + assert_receive {:volt_hmr, :update, %{changes: ["full"]}} + repeated = %{completed | tailwind_full?: true, pending_reloads: [page]} + + assert {:noreply, %{pending_reloads: []}} = + Volt.Watcher.handle_info(:tailwind_rebuild, repeated) + + assert_receive {:volt_hmr, :update, %{changes: ["full"]}} + refute_received {:volt_hmr, :update, %{changes: [:style]}} + end + + @tag :tmp_dir + test "external source roots are watched and unknown source extensions schedule Tailwind", %{ + tmp_dir: root + } do + assets = Path.join(root, "assets") + pages = Path.join(root, "pages") + File.mkdir_p!(assets) + File.mkdir_p!(pages) + source = Path.join(pages, "index.astral") + File.write!(source, "
") + css = Path.join(assets, "app.css") + File.write!(css, "@tailwind utilities;") + + watcher = + start_supervised!( + {Volt.Watcher, + root: assets, + name: nil, + tailwind: true, + tailwind_css: css, + tailwind_sources: [%{base: pages, pattern: "**/*.astral"}]} + ) + + state = :sys.get_state(watcher) + assert pages in state.tailwind_dirs + + assert {:noreply, scheduled} = + Volt.Watcher.handle_info({:file_event, self(), {source, [:modified]}}, state) + + assert source in scheduled.tailwind_changed + assert is_reference(scheduled.tailwind_timer) + Process.cancel_timer(scheduled.tailwind_timer) + end + test "broadcasts via registry on dispatch" do Registry.register(Volt.HMR.Registry, :clients, nil) @@ -188,10 +484,9 @@ defmodule Volt.WatcherTest do name: :test_watcher_reload_dirs ) - Process.sleep(100) - File.write!(page, "# Updated") + send_file_event(pid, page) - assert_receive {:volt_hmr, :update, %{path: path, changes: ["full"]}}, 2000 + assert_receive {:volt_hmr, :update, %{path: path, changes: ["full"]}} assert path == Path.relative_to_cwd(page) GenServer.stop(pid) @@ -220,7 +515,7 @@ defmodule Volt.WatcherTest do refute_receive {:volt_hmr, :update, %{path: "app.css", changes: [:style]}}, 50 - assert_receive {:volt_hmr, :update, %{path: "assets/css/app.css", changes: [:style]}}, + assert_receive {:volt_hmr, :update, %{path: "/assets/css/app.css", changes: [:style]}}, 3000 assert File.regular?(Path.join(outdir, "app.css")) @@ -249,7 +544,7 @@ defmodule Volt.WatcherTest do Process.sleep(100) File.write!(imported_css, ".imported-card { color: rgb(239 68 68); }") - assert_receive {:volt_hmr, :update, %{path: "assets/css/app.css", changes: [:style]}}, + assert_receive {:volt_hmr, :update, %{path: "/assets/css/app.css", changes: [:style]}}, 3000 css = File.read!(Path.join(outdir, "app.css")) @@ -279,7 +574,7 @@ defmodule Volt.WatcherTest do Process.sleep(100) File.write!(heex_file, ~s(
hi
)) - assert_receive {:volt_hmr, :update, %{path: "assets/css/app.css", changes: [:style]}}, + assert_receive {:volt_hmr, :update, %{path: "/assets/css/app.css", changes: [:style]}}, 3000 assert File.exists?(Path.join(outdir, "app.css"))