From e189539b5990e43a42c10e3da9a32fb7f8372dba Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 5 Sep 2026 12:34:53 +0300 Subject: [PATCH 01/22] refactor(tailwind): unify build and runtime ownership - Add Volt.build/1 with a typed complete-build result and one root manifest - Isolate Tailwind scanner state per profile and CSS root under supervision - Share one lazy QuickBEAM compiler runtime across Tailwind workers - Normalize manifests and emitted assets to typed structs - Keep Mix tasks as adapters and make development artifact identity configurable --- .reach.exs | 14 +- CHANGELOG.md | 17 ++ README.md | 8 +- guides/deployment/production-builds.md | 12 +- guides/features/tailwind.md | 14 ++ lib/mix/tasks/volt.build.ex | 96 ++------- lib/mix/tasks/volt.dev.ex | 46 ++-- lib/volt.ex | 4 + lib/volt/application.ex | 4 +- lib/volt/assets.ex | 6 +- lib/volt/build.ex | 92 ++++++++ lib/volt/build/result.ex | 12 ++ lib/volt/builder.ex | 13 +- lib/volt/builder/asset.ex | 8 + lib/volt/builder/bundle.ex | 2 +- lib/volt/builder/manifest_entry.ex | 17 +- lib/volt/builder/output/file.ex | 2 +- lib/volt/builder/writer.ex | 19 +- lib/volt/config/tailwind.ex | 38 ++++ lib/volt/css/asset_url_rewriter.ex | 2 +- lib/volt/dev_server.ex | 17 +- lib/volt/tailwind.ex | 198 ++---------------- lib/volt/tailwind/artifact.ex | 21 ++ lib/volt/tailwind/build.ex | 34 +++ lib/volt/tailwind/runtime.ex | 54 +++++ lib/volt/tailwind/state.ex | 6 + lib/volt/tailwind/supervisor.ex | 25 +++ lib/volt/tailwind/worker.ex | 100 +++++++++ lib/volt/watcher.ex | 77 +++++-- lib/volt/watcher/path.ex | 5 +- test/mix/tasks/volt.build_test.exs | 9 +- test/volt/build_test.exs | 71 +++++++ test/volt/config/tailwind_test.exs | 33 +++ test/volt/dev_server/basic_test.exs | 36 ++++ .../volt/integration/phoenix_example_test.exs | 34 +-- test/volt/tailwind/artifact_test.exs | 16 ++ test/volt/tailwind/worker_test.exs | 65 ++++++ test/volt/watcher/path_test.exs | 10 + test/volt/watcher_test.exs | 11 +- 39 files changed, 882 insertions(+), 366 deletions(-) create mode 100644 lib/volt/build.ex create mode 100644 lib/volt/build/result.ex create mode 100644 lib/volt/builder/asset.ex create mode 100644 lib/volt/config/tailwind.ex create mode 100644 lib/volt/tailwind/artifact.ex create mode 100644 lib/volt/tailwind/build.ex create mode 100644 lib/volt/tailwind/runtime.ex create mode 100644 lib/volt/tailwind/state.ex create mode 100644 lib/volt/tailwind/supervisor.ex create mode 100644 lib/volt/tailwind/worker.ex create mode 100644 test/volt/build_test.exs create mode 100644 test/volt/config/tailwind_test.exs create mode 100644 test/volt/tailwind/artifact_test.exs create mode 100644 test/volt/tailwind/worker_test.exs diff --git a/.reach.exs b/.reach.exs index 3008122..237d31f 100644 --- a/.reach.exs +++ b/.reach.exs @@ -22,6 +22,7 @@ adapter = [ ] orchestrator = [ + "Volt.Build", "Volt.Builder", "Volt.Builder.Collector", "Volt.Builder.Compiler", @@ -33,6 +34,7 @@ orchestrator = [ "Volt.JS.Vendor", "Volt.Pipeline", "Volt.Tailwind", + "Volt.Tailwind.Build", "Volt.Watcher", "Volt.Watcher.Ignore", "Volt.Watcher.Path" @@ -40,6 +42,7 @@ orchestrator = [ model = [ "Volt.Builder.BuildContext", + "Volt.Builder.Asset", "Volt.Builder.Bundle", "Volt.Builder.Collector.State", "Volt.Builder.Context", @@ -48,6 +51,8 @@ model = [ "Volt.Builder.OutputContext", "Volt.Builder.OutputFile", "Volt.Builder.Result", + "Volt.Build.Result", + "Volt.Config.Tailwind", "Volt.ChunkGraph.Chunk", "Volt.Config", "Volt.Config.*", @@ -61,7 +66,8 @@ model = [ "Volt.JS.TSConfig", "Volt.MIME", "Volt.Paths", - "Volt.Pipeline.Result.Hashes" + "Volt.Pipeline.Result.Hashes", + "Volt.Tailwind.State" ] logic = [ @@ -136,7 +142,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/CHANGELOG.md b/CHANGELOG.md index 0c677f2..0258a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +### 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 + +- 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 + +- Handle filesystem watcher events for the watched root itself when normalizing macOS path aliases. + ## 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/guides/deployment/production-builds.md b/guides/deployment/production-builds.md index 4c37a6d..f1c59c5 100644 --- a/guides/deployment/production-builds.md +++ b/guides/deployment/production-builds.md @@ -5,13 +5,11 @@ 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. @@ -25,7 +23,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 +100,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/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 index 2d6854f..55b83af 100644 --- a/lib/mix/tasks/volt.build.ex +++ b/lib/mix/tasks/volt.build.ex @@ -78,10 +78,6 @@ defmodule Mix.Tasks.Volt.Build do Keyword.get(parsed, :tailwind) || (tailwind_config != [] and Keyword.get(parsed, :tailwind, true)) - if tailwind? do - build_tailwind(parsed, tailwind_config, outdir, minify, config.hash, config.root) - end - entries = case cli_entries do [] -> List.wrap(config.entry) @@ -102,7 +98,7 @@ defmodule Mix.Tasks.Volt.Build do opts = [ entry: if(length(entries) == 1, do: hd(entries), else: entries), - outdir: Path.join(outdir, "js"), + outdir: outdir, 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 +118,27 @@ 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: if(tailwind?, do: tailwind_config, else: []), + 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 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 +146,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 +163,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 index fb9e219..0427ca2 100644 --- a/lib/mix/tasks/volt.dev.ex +++ b/lib/mix/tasks/volt.dev.ex @@ -52,9 +52,14 @@ 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)) + (Volt.Config.Tailwind.enabled?(tailwind_config) and Keyword.get(parsed, :tailwind, true)) cli_watch_dirs = Keyword.get_values(parsed, :watch_dir) cli_reload_dirs = Keyword.get_values(parsed, :reload_dir) @@ -80,10 +85,10 @@ 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] + tailwind_css = tailwind_root.css if tailwind? do - initial_build(tailwind_config, tailwind_css, parsed) + initial_build(tailwind_root, parsed, profile) end opts = [ @@ -91,8 +96,12 @@ defmodule Mix.Tasks.Volt.Dev do 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_name: tailwind_root.name, + tailwind_sources: tailwind_root.sources, + tailwind_url: tailwind_root.dev_url, tailwind_outdir: Keyword.get(parsed, :tailwind_outdir, Paths.static_css()), target: target ] @@ -118,25 +127,21 @@ defmodule Mix.Tasks.Volt.Dev do end end - 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)} - end - - case Volt.Tailwind.build(sources: sources, css: css_input, css_base: css_base) do + defp initial_build(root, parsed, profile) do + css_input = if root.css, do: File.read!(root.css) + css_base = if root.css, do: Path.dirname(root.css), else: File.cwd!() + key = tailwind_key(profile, root) + + case Volt.Tailwind.build( + key: key, + sources: root.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) + File.write!(Path.join(outdir, "#{root.name}.css"), css) Mix.shell().info( "[Volt] Initial Tailwind build: #{Volt.Format.format_size(byte_size(css))}" @@ -147,6 +152,9 @@ defmodule Mix.Tasks.Volt.Dev do 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/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..6580f8b 100644 --- a/lib/volt/application.ex +++ b/lib/volt/application.ex @@ -14,7 +14,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..fb78b25 100644 --- a/lib/volt/assets.ex +++ b/lib/volt/assets.ex @@ -95,7 +95,7 @@ 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 cond do Keyword.get(opts, :raw, false) -> @@ -147,9 +147,9 @@ defmodule Volt.Assets do 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 } diff --git a/lib/volt/build.ex b/lib/volt/build.ex new file mode 100644 index 0000000..5a1b165 --- /dev/null +++ b/lib/volt/build.ex @@ -0,0 +1,92 @@ +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) + + with {:ok, tailwind_result} <- build_tailwind(tailwind, config, opts, profile), + {:ok, asset_result} <- build_assets(entries, config, opts) do + tailwind_result = prefix_manifest(tailwind_result, "css") + asset_result = prefix_manifest(asset_result, "js") + result = build_result(tailwind_result, asset_result) + :ok = write_manifest(root_outdir, result.manifest) + {:ok, result} + end + end + + defp build_tailwind([], _config, _opts, _profile), do: {:ok, %Volt.Builder.Result{}} + defp build_tailwind(nil, _config, _opts, _profile), do: {:ok, %Volt.Builder.Result{}} + + defp build_tailwind(tailwind, config, opts, profile) 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) + + css_key = root.css || {:default_css, root.name} + + Volt.Tailwind.Build.build(root, + key: {:build, profile || :default, css_key}, + outdir: Path.join(to_string(config.outdir), "css"), + hash: config.hash, + minify: config.minify, + root: to_string(config.root), + asset_url_prefix: Volt.URL.join(config.asset_url_prefix, "css") + ) + end + + defp build_assets([], _config, _opts), do: {:ok, %Volt.Builder.Result{}} + + defp build_assets(entries, config, opts) do + builder_outdir = Path.join(to_string(config.outdir), "js") + asset_url_prefix = Volt.URL.join(config.asset_url_prefix, "js") + + 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.build() + end + + defp write_manifest(root_outdir, manifest) do + File.mkdir_p!(root_outdir) + Volt.Builder.Writer.write_manifest(root_outdir, manifest) + end + + defp build_result(styles_result, assets_result) do + %Volt.Build.Result{ + assets: assets_result, + styles: List.wrap(styles_result.css) ++ List.wrap(assets_result.css), + manifest: Map.merge(styles_result.manifest, assets_result.manifest) + } + end + + 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/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..01823a6 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`: @@ -73,12 +74,20 @@ 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) + with {:ok, result} <- finalize_build_results(results), + :ok <- maybe_write_manifest(build_ctx.outdir, result.manifest, opts) do {:ok, result} end end + defp maybe_write_manifest(outdir, manifest, opts) do + if Keyword.get(opts, :write_manifest, true) do + Volt.Builder.Writer.write_manifest(outdir, manifest) + else + :ok + end + end + defp shared_entries?(entries, build_ctx, name) do Keyword.get(build_ctx.bundle_opts, :format) == :esm and build_ctx.code_splitting and is_nil(name) and 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/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/manifest_entry.ex b/lib/volt/builder/manifest_entry.ex index ce5aa16..6bf5aec 100644 --- a/lib/volt/builder/manifest_entry.ex +++ b/lib/volt/builder/manifest_entry.ex @@ -25,6 +25,21 @@ defmodule Volt.Builder.ManifestEntry do 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 every emitted path in a manifest entry." + @spec prefix_paths(t(), String.t()) :: t() + def prefix_paths(%__MODULE__{} = entry, prefix) do + %{ + entry + | file: prefix_path(entry.file, prefix), + imports: Enum.map(entry.imports, &prefix_path(&1, prefix)), + dynamicImports: Enum.map(entry.dynamicImports, &prefix_path(&1, prefix)), + css: Enum.map(entry.css, &prefix_path(&1, prefix)), + assets: Enum.map(entry.assets, &prefix_path(&1, prefix)) + } + 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/output/file.ex b/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/writer.ex b/lib/volt/builder/writer.ex index ee49d92..c3bb63d 100644 --- a/lib/volt/builder/writer.ex +++ b/lib/volt/builder/writer.ex @@ -50,11 +50,12 @@ defmodule Volt.Builder.Writer do 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) @@ -118,8 +119,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 +127,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/config/tailwind.ex b/lib/volt/config/tailwind.ex new file mode 100644 index 0000000..d4b597a --- /dev/null +++ b/lib/volt/config/tailwind.ex @@ -0,0 +1,38 @@ +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() | nil) :: boolean() + def enabled?(config), do: is_list(config) and config != [] + + @spec new(keyword(), keyword()) :: t() + def new(config, overrides \\ []) 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..d50547c 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() diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index 0a2fbd2..a6f9180 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -54,12 +54,16 @@ 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 = if server_config.watch 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 @@ -71,8 +75,12 @@ defmodule Volt.DevServer do 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, @@ -107,6 +115,9 @@ defmodule Volt.DevServer do } end + 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, config) do Volt.Dev.ensure_watcher(config.watcher_opts) diff --git a/lib/volt/tailwind.ex b/lib/volt/tailwind.ex index d162271..a55ecb3 100644 --- a/lib/volt/tailwind.ex +++ b/lib/volt/tailwind.ex @@ -1,198 +1,30 @@ 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 - }} - 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} - 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 + opts + |> worker() + |> GenServer.call({:rebuild, changed_files, opts}, :infinity) end - defp to_oxide_source(%{base: base, pattern: pattern} = source) do - %Oxide.Source{ - base: Path.expand(base), - pattern: pattern, - negated: Map.get(source, :negated, false) - } + defp worker(opts) do + key = Keyword.get(opts, :key, @default_key) + Volt.Tailwind.Supervisor.worker(key, sources: Keyword.get(opts, :sources, [])) 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..382d47e --- /dev/null +++ b/lib/volt/tailwind/build.ex @@ -0,0 +1,34 @@ +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, css_input} <- read_css(root.css), + {:ok, css} <- + Volt.Tailwind.build( + key: Keyword.fetch!(opts, :key), + sources: root.sources, + css: css_input, + css_base: css_base(root.css), + minify: false + ) do + Volt.Builder.Writer.build_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 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/runtime.ex b/lib/volt/tailwind/runtime.ex new file mode 100644 index 0000000..8ed7e7f --- /dev/null +++ b/lib/volt/tailwind/runtime.ex @@ -0,0 +1,54 @@ +defmodule Volt.Tailwind.Runtime do + @moduledoc false + + use GenServer + + @name __MODULE__ + + def start_link(_opts) do + GenServer.start_link(__MODULE__, :ok, name: @name) + end + + @spec call(String.t() | nil, [String.t()], String.t()) :: {:ok, String.t()} | {:error, term()} + def call(css, candidates, css_base) do + GenServer.call(@name, {:compile, css, candidates, css_base}, :infinity) + end + + @impl true + def init(:ok), do: {:ok, nil} + + @impl true + def handle_call({:compile, css, candidates, css_base}, _from, runtime) do + runtime = runtime || start_runtime() + result = Volt.JS.Runtime.call(runtime, "compileTailwindCss", [css, candidates, css_base]) + {:reply, normalize_result(result), runtime} + end + + @impl true + def terminate(_reason, nil), do: :ok + + def terminate(_reason, runtime) do + Volt.JS.Runtime.stop(runtime) + :ok + 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..2d7239d --- /dev/null +++ b/lib/volt/tailwind/state.ex @@ -0,0 +1,6 @@ +defmodule Volt.Tailwind.State do + @moduledoc false + + @enforce_keys [:key, :sources] + defstruct [:key, :scanner, :last_css, :sources] +end diff --git a/lib/volt/tailwind/supervisor.ex b/lib/volt/tailwind/supervisor.ex new file mode 100644 index 0000000..4c72565 --- /dev/null +++ b/lib/volt/tailwind/supervisor.ex @@ -0,0 +1,25 @@ +defmodule Volt.Tailwind.Supervisor do + @moduledoc false + + @registry Volt.Tailwind.Registry + @supervisor Volt.Tailwind.WorkerSupervisor + + @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..3184ab4 --- /dev/null +++ b/lib/volt/tailwind/worker.ex @@ -0,0 +1,100 @@ +defmodule Volt.Tailwind.Worker do + @moduledoc false + + use GenServer + + alias Volt.Tailwind.State + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: Keyword.fetch!(opts, :name)) + end + + @impl true + def init(opts) do + {:ok, + %State{ + key: Keyword.fetch!(opts, :key), + scanner: nil, + last_css: nil, + sources: Keyword.get(opts, :sources, []) + }} + end + + @impl true + def handle_call({:build, opts}, _from, state) do + sources = opts[:sources] || state.sources + scanner = build_scanner(sources) + + case compile_css(opts[:css], scan(scanner), opts[:css_base]) do + {:ok, css} -> + css = maybe_minify(css, Keyword.get(opts, :minify, false)) + {:reply, {:ok, css}, %{state | scanner: scanner, last_css: css, sources: sources}} + + {:error, _reason} = error -> + {:reply, error, state} + end + end + + def handle_call({:rebuild, changed_files, opts}, _from, state) do + case state.scanner do + nil -> + {:reply, {:error, :no_scanner}, state} + + scanner -> + rebuild_css(state, scanner, changed_files, opts) + end + end + + defp rebuild_css(state, scanner, changed_files, opts) do + changed = Enum.map(changed_files, &changed_file/1) + + if Oxide.scan_files(scanner, changed) == [] do + {:reply, :unchanged, state} + else + compile_rebuilt_css(state, scanner, opts) + end + end + + defp compile_rebuilt_css(state, scanner, opts) do + case compile_css(opts[:css], Oxide.scan(scanner), opts[:css_base]) do + {:ok, css} -> + css = maybe_minify(css, Keyword.get(opts, :minify, false)) + reply = if css == state.last_css, do: :unchanged, else: {:ok, css} + {:reply, reply, %{state | last_css: css}} + + {:error, _reason} = error -> + {:reply, error, state} + end + 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) + + defp compile_css(css, candidates, css_base) do + Volt.Tailwind.Runtime.call(css, candidates, Path.expand(css_base || File.cwd!())) + 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/watcher.ex b/lib/volt/watcher.ex index aafdcf9..5b66801 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 @@ -68,6 +71,10 @@ defmodule Volt.Watcher do 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") + tailwind_key = Keyword.get(opts, :tailwind_key, {:watcher, root}) + configured_tailwind_sources = Keyword.get(opts, :tailwind_sources) config = opts @@ -77,11 +84,16 @@ defmodule Volt.Watcher do :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)]) + 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,6 +103,13 @@ defmodule Volt.Watcher do pid end) + config = + config + |> Map.put(:tailwind_key, tailwind_key) + |> Map.put(:tailwind_name, tailwind_name) + |> Map.put(:tailwind_sources, tailwind_sources) + |> Map.put(:tailwind_url, tailwind_url) + state = %__MODULE__{ root: root, fs_pids: fs_pids, @@ -102,14 +121,22 @@ defmodule Volt.Watcher do } 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 + ) end {:ok, state} end - defp initial_tailwind_build(dirs, css_path, outdir) do - case build_tailwind(dirs, css_path, outdir) do + defp watcher_sources(dirs), do: Enum.map(dirs, &%{base: &1, pattern: "**/*"}) + + defp initial_tailwind_build(sources, css_path, outdir, key, name) do + case build_tailwind(sources, css_path, outdir, key, name) do {:ok, css} -> Logger.debug("[Volt] Initial Tailwind build: #{byte_size(css)} bytes") @@ -118,16 +145,16 @@ 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) do 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( + key: key, + sources: sources, + css: css_input, + css_base: css_base + ) do + Volt.Tailwind.Artifact.write(outdir, name, css) {:ok, css} end end @@ -386,9 +413,15 @@ defmodule Volt.Watcher do end defp handle_tailwind_rebuild(_changed_paths, true, state) do - case build_tailwind(state.tailwind_dirs, state.config[:tailwind_css], state.tailwind_outdir) do + case build_tailwind( + state.config.tailwind_sources, + state.config[:tailwind_css], + state.tailwind_outdir, + state.config.tailwind_key, + state.config.tailwind_name + ) do {:ok, css} -> - HMR.broadcast(:update, %{path: "assets/css/app.css", changes: [:style]}) + HMR.broadcast(:update, %{path: state.config.tailwind_url, changes: [:style]}) Logger.debug("[Volt] Tailwind rebuilt (#{byte_size(css)} bytes)") {:error, reason} -> @@ -404,13 +437,15 @@ defmodule Volt.Watcher do 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) - end - - HMR.broadcast(:update, %{path: "assets/css/app.css", changes: [:style]}) + {:ok, css} <- + Volt.Tailwind.rebuild(changed, + key: state.config.tailwind_key, + 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]}) Logger.debug("[Volt] Tailwind rebuilt (#{byte_size(css)} bytes)") else :unchanged -> 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/test/mix/tasks/volt.build_test.exs b/test/mix/tasks/volt.build_test.exs index 741b083..513fa59 100644 --- a/test/mix/tasks/volt.build_test.exs +++ b/test/mix/tasks/volt.build_test.exs @@ -81,7 +81,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 +142,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/volt/build_test.exs b/test/volt/build_test.exs new file mode 100644 index 0000000..83b2379 --- /dev/null +++ b/test/volt/build_test.exs @@ -0,0 +1,71 @@ +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 "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 "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/config/tailwind_test.exs b/test/volt/config/tailwind_test.exs new file mode 100644 index 0000000..2bc959e --- /dev/null +++ b/test/volt/config/tailwind_test.exs @@ -0,0 +1,33 @@ +defmodule Volt.Config.TailwindTest do + use ExUnit.Case, async: true + + 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/dev_server/basic_test.exs b/test/volt/dev_server/basic_test.exs index 47294d4..50adb08 100644 --- a/test/volt/dev_server/basic_test.exs +++ b/test/volt/dev_server/basic_test.exs @@ -41,6 +41,42 @@ defmodule Volt.DevServer.BasicTest do 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 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 opts = Volt.DevServer.init(root: Path.join(@fixture_dir, "src"), prefix: "/assets") 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/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/worker_test.exs b/test/volt/tailwind/worker_test.exs new file mode 100644 index 0000000..6d30d05 --- /dev/null +++ b/test/volt/tailwind/worker_test.exs @@ -0,0 +1,65 @@ +defmodule Volt.Tailwind.WorkerTest do + use ExUnit.Case, async: false + + @moduletag :tmp_dir + + 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/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..3aeb2e4 100644 --- a/test/volt/watcher_test.exs +++ b/test/volt/watcher_test.exs @@ -188,10 +188,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 +219,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 +248,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 +278,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")) From 21f190a7e2081dd08ba30704487289c4c4bc640d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 8 Sep 2026 14:27:07 +0300 Subject: [PATCH 02/22] Refactor asset preparation and scope development state by session --- .reach.exs | 7 + guides/deployment/production-builds.md | 41 +++ .../tasks/{volt.build.ex => volt/build.ex} | 29 +- lib/mix/tasks/{volt.dev.ex => volt/dev.ex} | 0 .../{volt.install.ex => volt/install.ex} | 0 .../{volt.js.check.ex => volt/js/check.ex} | 0 .../{volt.js.format.ex => volt/js/format.ex} | 0 lib/mix/tasks/{volt.lint.ex => volt/lint.ex} | 0 lib/volt/assets.ex | 52 +++- lib/volt/build.ex | 103 +++++-- lib/volt/build/layout.ex | 36 +++ lib/volt/builder.ex | 237 ++++++++++++---- lib/volt/builder/artifact.ex | 33 +++ lib/volt/builder/compiled.ex | 5 + lib/volt/builder/css.ex | 36 +++ lib/volt/builder/css/prepared.ex | 12 + lib/volt/builder/manifest_entry.ex | 11 +- lib/volt/builder/output.ex | 222 ++++++++++----- lib/volt/builder/plan.ex | 69 +++++ lib/volt/builder/publication.ex | 98 +++++++ lib/volt/builder/result.ex | 3 +- lib/volt/builder/writer.ex | 78 ++++-- lib/volt/cache.ex | 33 ++- lib/volt/config/build.ex | 2 + lib/volt/css/asset_url_rewriter.ex | 58 +++- lib/volt/css/imports.ex | 2 + lib/volt/dev.ex | 45 ++- lib/volt/dev_server.ex | 32 ++- lib/volt/dev_server/config.ex | 1 + lib/volt/ets.ex | 6 + lib/volt/hmr.ex | 21 +- lib/volt/hmr/boundary.ex | 51 ++-- lib/volt/hmr/channel.ex | 6 + lib/volt/hmr/glob_graph.ex | 22 +- lib/volt/hmr/import_graph.ex | 37 +-- lib/volt/hmr/module_graph.ex | 176 ++++++------ lib/volt/hmr/socket.ex | 7 +- lib/volt/hmr/style_dependencies.ex | 6 +- lib/volt/hmr/style_graph.ex | 44 +-- lib/volt/preload.ex | 10 +- lib/volt/public_dir.ex | 38 +++ lib/volt/tailwind/build.ex | 37 ++- lib/volt/tailwind/loader.ex | 18 +- lib/volt/tailwind/runtime.ex | 15 + lib/volt/watcher.ex | 152 ++++++---- priv/ts/compilers/tailwind.ts | 52 +++- .../build_test.exs} | 81 ++++++ .../install_test.exs} | 0 .../js/check_test.exs} | 0 .../lint_test.exs} | 0 test/volt/assets_test.exs | 18 ++ test/volt/build/layout_test.exs | 17 ++ test/volt/build_test.exs | 260 ++++++++++++++++++ test/volt/builder/artifact_test.exs | 39 +++ test/volt/builder/manifest_entry_test.exs | 25 ++ test/volt/builder/output_test.exs | 57 ++++ test/volt/builder/plan_test.exs | 92 +++++++ test/volt/builder/publication_test.exs | 67 +++++ .../css_test.exs => stylesheets_test.exs} | 52 +++- test/volt/builder/workers_and_assets_test.exs | 48 ++++ test/volt/cache_test.exs | 18 ++ test/volt/css/asset_url_rewriter_test.exs | 17 ++ test/volt/css/imports_test.exs | 50 ++++ test/volt/dev_test.exs | 22 ++ test/volt/hmr/boundary_test.exs | 32 +++ test/volt/hmr/glob_graph_test.exs | 13 + test/volt/hmr/style_graph_test.exs | 13 + test/volt/hmr_test.exs | 36 +++ test/volt/preload_test.exs | 16 ++ test/volt/public_dir_test.exs | 39 +++ test/volt/tailwind/build_test.exs | 124 +++++++++ test/volt/tailwind/runtime_test.exs | 25 ++ 72 files changed, 2594 insertions(+), 510 deletions(-) rename lib/mix/tasks/{volt.build.ex => volt/build.ex} (86%) rename lib/mix/tasks/{volt.dev.ex => volt/dev.ex} (100%) rename lib/mix/tasks/{volt.install.ex => volt/install.ex} (100%) rename lib/mix/tasks/{volt.js.check.ex => volt/js/check.ex} (100%) rename lib/mix/tasks/{volt.js.format.ex => volt/js/format.ex} (100%) rename lib/mix/tasks/{volt.lint.ex => volt/lint.ex} (100%) create mode 100644 lib/volt/build/layout.ex create mode 100644 lib/volt/builder/artifact.ex create mode 100644 lib/volt/builder/compiled.ex create mode 100644 lib/volt/builder/css/prepared.ex create mode 100644 lib/volt/builder/plan.ex create mode 100644 lib/volt/builder/publication.ex create mode 100644 lib/volt/hmr/channel.ex rename test/mix/tasks/{volt.build_test.exs => volt/build_test.exs} (60%) rename test/mix/tasks/{volt.install_test.exs => volt/install_test.exs} (100%) rename test/mix/tasks/{volt.js.check_test.exs => volt/js/check_test.exs} (100%) rename test/mix/tasks/{volt.lint_test.exs => volt/lint_test.exs} (100%) create mode 100644 test/volt/build/layout_test.exs create mode 100644 test/volt/builder/artifact_test.exs create mode 100644 test/volt/builder/manifest_entry_test.exs create mode 100644 test/volt/builder/output_test.exs create mode 100644 test/volt/builder/plan_test.exs create mode 100644 test/volt/builder/publication_test.exs rename test/volt/builder/{output/css_test.exs => stylesheets_test.exs} (82%) create mode 100644 test/volt/css/imports_test.exs create mode 100644 test/volt/public_dir_test.exs create mode 100644 test/volt/tailwind/build_test.exs create mode 100644 test/volt/tailwind/runtime_test.exs diff --git a/.reach.exs b/.reach.exs index 237d31f..db833f2 100644 --- a/.reach.exs +++ b/.reach.exs @@ -46,12 +46,14 @@ model = [ "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", @@ -59,6 +61,7 @@ model = [ "Volt.DevServer.CacheEntry", "Volt.DevServer.Config", "Volt.HMR.Message", + "Volt.HMR.Channel", "Volt.JS.ImportExtractor.Result", "Volt.JS.PrebundleEntry.Export", "Volt.JS.PrebundleEntry.Import", @@ -71,6 +74,9 @@ model = [ ] logic = [ + "Volt.Builder.Artifact", + "Volt.Builder.CSS.Prepared", + "Volt.Builder.Plan", "Volt.Assets", "Volt.Assets.Query", "Volt.Builder.Naming", @@ -121,6 +127,7 @@ infrastructure = [ "Volt.Application", "Volt.Builder.CSS", "Volt.Builder.Writer", + "Volt.Builder.Publication", "Volt.Cache", "Volt.Dev", "Volt.Dev.ConsoleForwarder", diff --git a/guides/deployment/production-builds.md b/guides/deployment/production-builds.md index f1c59c5..a9325da 100644 --- a/guides/deployment/production-builds.md +++ b/guides/deployment/production-builds.md @@ -14,6 +14,47 @@ 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: diff --git a/lib/mix/tasks/volt.build.ex b/lib/mix/tasks/volt/build.ex similarity index 86% rename from lib/mix/tasks/volt.build.ex rename to lib/mix/tasks/volt/build.ex index 55b83af..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,9 +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 + + {: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 @@ -99,6 +111,8 @@ defmodule Mix.Tasks.Volt.Build do opts = [ entry: if(length(entries) == 1, do: hd(entries), else: entries), 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), @@ -120,7 +134,7 @@ defmodule Mix.Tasks.Volt.Build do root: config.root, name: parsed[:name], profile: profile, - tailwind: if(tailwind?, do: tailwind_config, else: []), + tailwind: tailwind, tailwind_css: parsed[:tailwind_css], tailwind_sources: tailwind_sources(parsed, tailwind_config) ] @@ -130,6 +144,13 @@ defmodule Mix.Tasks.Volt.Build do build_all(opts) end + 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])}...") diff --git a/lib/mix/tasks/volt.dev.ex b/lib/mix/tasks/volt/dev.ex similarity index 100% rename from lib/mix/tasks/volt.dev.ex rename to lib/mix/tasks/volt/dev.ex 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 100% rename from lib/mix/tasks/volt.js.check.ex rename to lib/mix/tasks/volt/js/check.ex diff --git a/lib/mix/tasks/volt.js.format.ex b/lib/mix/tasks/volt/js/format.ex similarity index 100% rename from lib/mix/tasks/volt.js.format.ex rename to lib/mix/tasks/volt/js/format.ex diff --git a/lib/mix/tasks/volt.lint.ex b/lib/mix/tasks/volt/lint.ex similarity index 100% rename from lib/mix/tasks/volt.lint.ex rename to lib/mix/tasks/volt/lint.ex diff --git a/lib/volt/assets.ex b/lib/volt/assets.ex index fb78b25..153f993 100644 --- a/lib/volt/assets.ex +++ b/lib/volt/assets.ex @@ -97,6 +97,29 @@ defmodule Volt.Assets do @spec emit_js_module(String.t(), keyword()) :: {: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,17 +156,16 @@ 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." @@ -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 index 5a1b165..48ec566 100644 --- a/lib/volt/build.ex +++ b/lib/volt/build.ex @@ -13,21 +13,44 @@ defmodule Volt.Build do tailwind = Keyword.get(opts, :tailwind, Volt.Config.tailwind(profile)) entries = List.wrap(config.entry) root_outdir = to_string(config.outdir) - - with {:ok, tailwind_result} <- build_tailwind(tailwind, config, opts, profile), - {:ok, asset_result} <- build_assets(entries, config, opts) do - tailwind_result = prefix_manifest(tailwind_result, "css") - asset_result = prefix_manifest(asset_result, "js") - result = build_result(tailwind_result, asset_result) - :ok = write_manifest(root_outdir, result.manifest) - {:ok, result} + 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([], _config, _opts, _profile), do: {:ok, %Volt.Builder.Result{}} - defp build_tailwind(nil, _config, _opts, _profile), do: {:ok, %Volt.Builder.Result{}} + 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 build_tailwind(tailwind, config, opts, profile) do + defp prepare_tailwind(tailwind, config, opts, layout) do overrides = [] |> put_present(:css, opts[:tailwind_css]) @@ -36,23 +59,20 @@ defmodule Volt.Build do root = Volt.Config.Tailwind.new(tailwind, overrides) - css_key = root.css || {:default_css, root.name} - - Volt.Tailwind.Build.build(root, - key: {:build, profile || :default, css_key}, - outdir: Path.join(to_string(config.outdir), "css"), + 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, "css") + asset_url_prefix: Volt.URL.join(config.asset_url_prefix, layout.styles) ) end - defp build_assets([], _config, _opts), do: {:ok, %Volt.Builder.Result{}} + defp build_assets([], _config, _opts, _layout), do: empty_plan() - defp build_assets(entries, config, opts) do - builder_outdir = Path.join(to_string(config.outdir), "js") - asset_url_prefix = Volt.URL.join(config.asset_url_prefix, "js") + 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() @@ -62,22 +82,45 @@ defmodule Volt.Build do |> Map.put(:write_manifest, false) |> Map.merge(Map.new(Keyword.take(opts, [:name]))) |> Map.to_list() - |> Volt.Builder.build() + |> Volt.Builder.prepare() end - defp write_manifest(root_outdir, manifest) do - File.mkdir_p!(root_outdir) - Volt.Builder.Writer.write_manifest(root_outdir, manifest) + 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 - %Volt.Build.Result{ - assets: assets_result, - styles: List.wrap(styles_result.css) ++ List.wrap(assets_result.css), - manifest: Map.merge(styles_result.manifest, assets_result.manifest) - } + 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} -> 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/builder.ex b/lib/volt/builder.ex index 01823a6..abc8c9a 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -54,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 = @@ -74,17 +90,17 @@ defmodule Volt.Builder do build_isolated_entries(expanded_entries, ctx, build_ctx) end - with {:ok, result} <- finalize_build_results(results), - :ok <- maybe_write_manifest(build_ctx.outdir, result.manifest, opts) do - {:ok, result} - end - end - - defp maybe_write_manifest(outdir, manifest, opts) do - if Keyword.get(opts, :write_manifest, true) do - Volt.Builder.Writer.write_manifest(outdir, manifest) - else - :ok + 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 @@ -98,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) @@ -121,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, @@ -132,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() @@ -284,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, @@ -309,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 @@ -325,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, @@ -457,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) @@ -470,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 @@ -511,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 @@ -531,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 @@ -561,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 @@ -587,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 @@ -599,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 = @@ -636,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 @@ -848,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..8d2c7b2 --- /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.Format.content_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/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/manifest_entry.ex b/lib/volt/builder/manifest_entry.ex index 6bf5aec..8f5affb 100644 --- a/lib/volt/builder/manifest_entry.ex +++ b/lib/volt/builder/manifest_entry.ex @@ -20,6 +20,13 @@ 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 @@ -27,14 +34,12 @@ defmodule Volt.Builder.ManifestEntry do 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 every emitted path in a manifest entry." + @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), - imports: Enum.map(entry.imports, &prefix_path(&1, prefix)), - dynamicImports: Enum.map(entry.dynamicImports, &prefix_path(&1, prefix)), css: Enum.map(entry.css, &prefix_path(&1, prefix)), assets: Enum.map(entry.assets, &prefix_path(&1, prefix)) } diff --git a/lib/volt/builder/output.ex b/lib/volt/builder/output.ex index 5b5f659..70a3c2c 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,18 +207,25 @@ 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 + manifest = bundle_result.outputs |> Enum.reduce(%{}, fn output, acc -> @@ -186,13 +234,16 @@ defmodule Volt.Builder.Output do |> 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 +263,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 +277,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 +305,13 @@ 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) - - :ok end defp output_manifest_entry(%{type: :asset} = output, _css_results, _assets, acc) do @@ -292,7 +344,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 +374,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 +390,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 +405,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 +459,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 +620,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/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 c3bb63d..b4ba3a9 100644 --- a/lib/volt/builder/writer.ex +++ b/lib/volt/builder/writer.ex @@ -2,52 +2,71 @@ 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" => @@ -63,8 +82,9 @@ defmodule Volt.Builder.Writer do %Volt.Builder.Result{ js: [], css: css_result, + styles: [css_result], manifest: manifest - }} + }, plan} end end diff --git a/lib/volt/cache.ex b/lib/volt/cache.ex index 13e702d..6d9139e 100644 --- a/lib/volt/cache.ex +++ b/lib/volt/cache.ex @@ -21,44 +21,51 @@ 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 + key = {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 + key = {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 + :ets.insert(@table, {{session, path}, %{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 + :ets.delete(@table, {session, 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/css/asset_url_rewriter.ex b/lib/volt/css/asset_url_rewriter.ex index d50547c..c22ad70 100644 --- a/lib/volt/css/asset_url_rewriter.ex +++ b/lib/volt/css/asset_url_rewriter.ex @@ -26,18 +26,53 @@ 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} @@ -56,8 +91,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 +102,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} @@ -100,15 +135,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..735e632 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,25 +26,60 @@ defmodule Volt.Dev do end end - defp start_watcher(opts) do + @doc "Start or reuse a managed watcher, rejecting conflicting configuration." + def start(opts) do {id, opts} = Keyword.pop(opts, :id, :default) - key = {:watcher, id, opts |> Keyword.fetch!(:root) |> Path.expand()} + + session = Keyword.get(opts, :session, :default) + root = opts |> Keyword.fetch!(:root) |> Path.expand() + key = if session == :default, do: {:watcher, id, root}, else: {:session, session} + opts = Keyword.put(opts, :root, root) name = {:via, Registry, {@registry, key}} case Registry.lookup(@registry, key) do [{pid, _}] -> - {:ok, pid} + verify_configuration(pid, opts) [] -> opts = Keyword.put(opts, :name, name) case DynamicSupervisor.start_child(@supervisor, {Volt.Watcher, opts}) do - {:error, {:already_started, pid}} -> {:ok, pid} + {:error, {:already_started, pid}} -> verify_configuration(pid, opts) result -> result end 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 + case Registry.lookup(@registry, {:session, session}) do + [] -> :ok + [{pid, _}] -> DynamicSupervisor.terminate_child(@supervisor, pid) + end + |> case do + :ok -> + 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 + + error -> + error + end + end + + defp verify_configuration(pid, opts) do + signature = opts |> Keyword.delete(:name) |> Map.new() + + if GenServer.call(pid, {:configuration_matches, signature}), + do: {:ok, pid}, + else: {:error, :session_configuration_conflict} + end + defp dev_supervision_started? do is_pid(Process.whereis(@registry)) and is_pid(Process.whereis(@supervisor)) end diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index a6f9180..60f6bae 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -71,6 +71,7 @@ defmodule Volt.DevServer do [ id: profile || :default, + session: Keyword.get(opts, :session, :default), root: expanded_root, watch_dirs: watch_dirs, reload_dirs: server_config.reload_dirs, @@ -111,6 +112,7 @@ defmodule Volt.DevServer do define: Volt.Env.define(mode: "development", root: File.cwd!(), env_prefix: config.env_prefix), hmr_timeout: server_config.hmr_timeout, + session: Keyword.get(opts, :session, :default), watcher_opts: watcher_opts } end @@ -126,7 +128,9 @@ defmodule Volt.DevServer do 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 @@ -221,7 +225,7 @@ 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.session) send_compiled(conn, code, result.sourcemap, content_type) @@ -286,7 +290,7 @@ defmodule Volt.DevServer do 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.session) do %{code: code, sourcemap: sourcemap} -> send_compiled(conn, code, sourcemap, content_type) @@ -319,15 +323,24 @@ 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.session) + Volt.HMR.ImportGraph.update_from_compiled(file_path, result.code, config.session) + Volt.HMR.StyleDependencies.update_from_compile(file_path, source, result, 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.session + ) entry = %Volt.DevServer.CacheEntry{ code: code, @@ -337,7 +350,7 @@ defmodule Volt.DevServer do content_type: content_type } - Volt.Cache.put(cache_key, mtime, entry) + Volt.Cache.put(cache_key, mtime, entry, config.session) send_compiled(conn, code, result.sourcemap, content_type) {:error, errors} -> @@ -364,7 +377,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)) @@ -372,6 +385,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) ) diff --git a/lib/volt/dev_server/config.ex b/lib/volt/dev_server/config.ex index 7dc82e4..520b704 100644 --- a/lib/volt/dev_server/config.ex +++ b/lib/volt/dev_server/config.ex @@ -15,5 +15,6 @@ defmodule Volt.DevServer.Config do module_types: %{}, define: %{}, hmr_timeout: 60_000, + session: :default, watcher_opts: nil end diff --git a/lib/volt/ets.ex b/lib/volt/ets.ex index 5ee8173..9743291 100644 --- a/lib/volt/ets.ex +++ b/lib/volt/ets.ex @@ -19,6 +19,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/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..6c00f66 100644 --- a/lib/volt/hmr/glob_graph.ex +++ b/lib/volt/hmr/glob_graph.ex @@ -7,30 +7,38 @@ defmodule Volt.HMR.GlobGraph do 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, 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 ) end - def remove(path), do: Volt.ETS.delete(@table, path) + def remove(path, session \\ :default), do: Volt.ETS.delete(@table, {session, path}) + + @doc "Remove glob ownership for one session." + def clear_session(session), do: Volt.ETS.clear_session(@table, 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..0e91d67 100644 --- a/lib/volt/hmr/import_graph.ex +++ b/lib/volt/hmr/import_graph.ex @@ -15,24 +15,26 @@ defmodule Volt.HMR.ImportGraph do @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, 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, path}) do [{_, imports}] -> imports [] -> [] end @@ -44,14 +46,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,10 +57,14 @@ 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 @@ -73,9 +73,12 @@ defmodule Volt.HMR.ImportGraph do @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, 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) end diff --git a/lib/volt/hmr/module_graph.ex b/lib/volt/hmr/module_graph.ex index fed725d..6e658d4 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,118 @@ 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 + 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, {: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, {:url, node.url}}) + ETS.delete(@table, {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, {: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) + + defp nodes(session) do + :ets.foldl( + fn + {{^session, {:id, _}}, node}, acc -> [node | acc] + _, acc -> acc + end, + [], + @table + ) 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, {:url, node.url}}, node.id}) + ETS.put(@table, {{session, {:id, node.id}}, node}) + ETS.put(@table, {{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, {: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, 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..75f0154 100644 --- a/lib/volt/hmr/style_graph.ex +++ b/lib/volt/hmr/style_graph.ex @@ -16,20 +16,20 @@ defmodule Volt.HMR.StyleGraph do @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, {: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 +37,19 @@ 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}}) @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, {:dependencies, path}}) + Volt.ETS.delete(@table, {session, {:dependents, path}}) :ok end @@ -56,23 +57,26 @@ 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) + + 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}}) - 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, {:dependents, path}}) else - Volt.ETS.put(@table, {{:dependents, path}, dependents}) + Volt.ETS.put(@table, {{session, {:dependents, path}}, dependents}) end end 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/build.ex b/lib/volt/tailwind/build.ex index 382d47e..e7801a3 100644 --- a/lib/volt/tailwind/build.ex +++ b/lib/volt/tailwind/build.ex @@ -4,16 +4,28 @@ defmodule Volt.Tailwind.Build do @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.build( - key: Keyword.fetch!(opts, :key), - sources: root.sources, - css: css_input, - css_base: css_base(root.css), - minify: false + 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.build_style_entry( + Volt.Builder.Writer.prepare_style_entry( root.name, css, Keyword.fetch!(opts, :outdir), @@ -26,6 +38,17 @@ defmodule Volt.Tailwind.Build do 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) diff --git a/lib/volt/tailwind/loader.ex b/lib/volt/tailwind/loader.ex index 2a8a723..59c80f3 100644 --- a/lib/volt/tailwind/loader.ex +++ b/lib/volt/tailwind/loader.ex @@ -16,8 +16,16 @@ 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] -> + load_stylesheet(id, base, output_base, runtime_node_modules) end, "tailwind.load_module" => fn [id, base, kind] -> load_module(id, base, kind, runtime_node_modules) @@ -25,12 +33,14 @@ defmodule Volt.Tailwind.Loader do } end - defp load_stylesheet(id, base, runtime_node_modules) do + 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 diff --git a/lib/volt/tailwind/runtime.ex b/lib/volt/tailwind/runtime.ex index 8ed7e7f..2ce1337 100644 --- a/lib/volt/tailwind/runtime.ex +++ b/lib/volt/tailwind/runtime.ex @@ -14,6 +14,21 @@ defmodule Volt.Tailwind.Runtime do GenServer.call(@name, {:compile, 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: {:ok, nil} diff --git a/lib/volt/watcher.ex b/lib/volt/watcher.ex index 5b66801..957ab2f 100644 --- a/lib/volt/watcher.ex +++ b/lib/volt/watcher.ex @@ -47,6 +47,8 @@ defmodule Volt.Watcher do defstruct [ :root, :config, + :configuration_signature, + session: :default, fs_pids: [], pending: %{}, tailwind_timer: nil, @@ -81,6 +83,7 @@ defmodule Volt.Watcher do |> Keyword.drop([ :root, :name, + :session, :watch_dirs, :reload_dirs, :watch_ignored, @@ -112,6 +115,8 @@ defmodule Volt.Watcher do state = %__MODULE__{ root: root, + configuration_signature: opts |> Keyword.delete(:name) |> Map.new(), + session: Keyword.get(opts, :session, :default), fs_pids: fs_pids, config: config, tailwind_outdir: tailwind_outdir, @@ -133,6 +138,11 @@ defmodule Volt.Watcher do {:ok, state} end + @impl true + def handle_call({:configuration_matches, signature}, _from, state) do + {:reply, state.configuration_signature == signature, state} + end + defp watcher_sources(dirs), do: Enum.map(dirs, &%{base: &1, pattern: "**/*"}) defp initial_tailwind_build(sources, css_path, outdir, key, name) do @@ -197,7 +207,7 @@ defmodule Volt.Watcher do {:noreply, state} reload_path?(path, state) -> - handle_reload_change(path) + handle_reload_change(path, state) {:noreply, state} true -> @@ -267,40 +277,40 @@ 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: [] + css_dependents = if css?, do: Volt.HMR.StyleGraph.dependents(path, state.session), else: [] - old_entry = Volt.Cache.get_file(path) - Volt.Cache.evict_file(path) - Volt.HMR.ModuleGraph.invalidate_file(path) + old_entry = Volt.Cache.get_file(path, state.session) + Volt.Cache.evict_file(path, state.session) + Volt.HMR.ModuleGraph.invalidate_file(path, System.system_time(:millisecond), 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.session) + Volt.HMR.ImportGraph.update_from_compiled(path, result.code, state.session) - Volt.HMR.StyleDependencies.update_from_compile(path, source, result) + Volt.HMR.StyleDependencies.update_from_compile(path, source, result, 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.session) + Volt.HMR.GlobGraph.remove(path, state.session) + if css?, do: Volt.HMR.StyleGraph.remove(path, state.session) + Volt.HMR.ModuleGraph.remove_file(path, 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 @@ -308,23 +318,23 @@ 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.session) tailwind_input? = tailwind_input?(path, state) - Volt.Cache.evict_file(path) - Volt.HMR.ModuleGraph.invalidate_file(path) + Volt.Cache.evict_file(path, state.session) + Volt.HMR.ModuleGraph.invalidate_file(path, System.system_time(:millisecond), 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.session) else - StyleGraph.remove(path) + StyleGraph.remove(path, 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) @@ -340,48 +350,54 @@ 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.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.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.Cache.evict_file(path, state.session) + Volt.HMR.ModuleGraph.invalidate_file(path, System.system_time(:millisecond), 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.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.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 @@ -394,20 +410,24 @@ 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.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 @@ -421,11 +441,16 @@ defmodule Volt.Watcher do state.config.tailwind_name ) do {:ok, css} -> - HMR.broadcast(:update, %{path: state.config.tailwind_url, changes: [:style]}) + HMR.broadcast(:update, %{path: state.config.tailwind_url, changes: [:style]}, + session: state.session + ) + Logger.debug("[Volt] Tailwind rebuilt (#{byte_size(css)} bytes)") {:error, reason} -> - HMR.broadcast(:error, %{path: "tailwind", reason: inspect(reason)}) + HMR.broadcast(:error, %{path: "tailwind", reason: inspect(reason)}, + session: state.session + ) end end @@ -445,14 +470,19 @@ defmodule Volt.Watcher do ) do Volt.Tailwind.Artifact.write(state.tailwind_outdir, state.config.tailwind_name, css) - HMR.broadcast(:update, %{path: state.config.tailwind_url, changes: [:style]}) + HMR.broadcast(:update, %{path: state.config.tailwind_url, changes: [:style]}, + session: state.session + ) + Logger.debug("[Volt] Tailwind rebuilt (#{byte_size(css)} bytes)") else :unchanged -> :ok {:error, reason} -> - HMR.broadcast(:error, %{path: "tailwind", reason: inspect(reason)}) + HMR.broadcast(:error, %{path: "tailwind", reason: inspect(reason)}, + session: state.session + ) end end diff --git a/priv/ts/compilers/tailwind.ts b/priv/ts/compilers/tailwind.ts index dad2af4..08dc9b5 100644 --- a/priv/ts/compilers/tailwind.ts +++ b/priv/ts/compilers/tailwind.ts @@ -5,20 +5,29 @@ type BeamModuleSpec = { 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 } @@ -69,7 +78,8 @@ const tailwindExports = requireResolvedModule(tailwindRuntimeSpec, new Map()) as async function compileTailwindCss( inputCss: string | null, candidates: string[] | null, - base: string | null + base: string | null, + scan: { base: string; sources: Source[] } | null = null ) { const moduleCache = new Map() const rootBase = normalizeBase(base, TAILWIND_DEFAULT_BASE) @@ -81,6 +91,7 @@ async function compileTailwindCss( 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);' @@ -89,24 +100,34 @@ async function compileTailwindCss( 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') } + return { + path: preflightCssPath, + base: rootBase, + content: fs.readFileSync(preflightCssPath, 'utf8') + } } if (id === 'tailwindcss/utilities.css') { - return { base: rootBase, content: fs.readFileSync(utilitiesCssPath, 'utf8') } + return { + path: utilitiesCssPath, + base: rootBase, + content: fs.readFileSync(utilitiesCssPath, 'utf8') + } } return Beam.callSync( 'tailwind.load_stylesheet', id, - normalizeBase(currentBase, rootBase) - ) as { base: string; content: string } + normalizeBase(currentBase, rootBase), + rootBase + ) as { path: string; base: string; content: string } }, loadModule: async (id, currentBase, type) => { const spec = Beam.callSync( @@ -117,10 +138,23 @@ async function compileTailwindCss( ) as BeamModuleSpec const mod = requireResolvedModule(spec, moduleCache) - return { module: unwrapModule(mod), base: spec.base } + return { path: spec.path, 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 }] + } + candidates = Beam.callSync('tailwind.scan', [ + ...automatic, + ...scan.sources, + ...compiler.sources + ]) as string[] + } return compiler.build(candidates ?? []) } diff --git a/test/mix/tasks/volt.build_test.exs b/test/mix/tasks/volt/build_test.exs similarity index 60% rename from test/mix/tasks/volt.build_test.exs rename to test/mix/tasks/volt/build_test.exs index 513fa59..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") diff --git a/test/mix/tasks/volt.install_test.exs b/test/mix/tasks/volt/install_test.exs similarity index 100% rename from test/mix/tasks/volt.install_test.exs rename to test/mix/tasks/volt/install_test.exs diff --git a/test/mix/tasks/volt.js.check_test.exs b/test/mix/tasks/volt/js/check_test.exs similarity index 100% rename from test/mix/tasks/volt.js.check_test.exs rename to test/mix/tasks/volt/js/check_test.exs diff --git a/test/mix/tasks/volt.lint_test.exs b/test/mix/tasks/volt/lint_test.exs similarity index 100% rename from test/mix/tasks/volt.lint_test.exs rename to test/mix/tasks/volt/lint_test.exs 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 index 83b2379..da46f0f 100644 --- a/test/volt/build_test.exs +++ b/test/volt/build_test.exs @@ -14,6 +14,180 @@ defmodule Volt.BuildTest do {:ok, root: root, outdir: outdir} 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( @@ -51,6 +225,92 @@ defmodule Volt.BuildTest do 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( diff --git a/test/volt/builder/artifact_test.exs b/test/volt/builder/artifact_test.exs new file mode 100644 index 0000000..d44aefc --- /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.Format.content_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..bb9661d --- /dev/null +++ b/test/volt/builder/manifest_entry_test.exs @@ -0,0 +1,25 @@ +defmodule Volt.Builder.ManifestEntryTest do + use ExUnit.Case, async: true + + alias Volt.Builder.ManifestEntry + + 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/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/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_test.exs b/test/volt/dev_test.exs index b391b8f..c912347 100644 --- a/test/volt/dev_test.exs +++ b/test/volt/dev_test.exs @@ -1,6 +1,28 @@ defmodule Volt.DevTest do use ExUnit.Case, async: false + @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 + 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) 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/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/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..bcb86c5 --- /dev/null +++ b/test/volt/tailwind/runtime_test.exs @@ -0,0 +1,25 @@ +defmodule Volt.Tailwind.RuntimeTest do + use ExUnit.Case, async: false + + 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 From 83f795e2061ce9845652394ac893577e81df99d2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 9 Sep 2026 16:42:08 +0300 Subject: [PATCH 03/22] fix(vendor): isolate cached bundles by compilation context --- lib/volt/js/vendor.ex | 76 +++++++++++++++++------------ test/volt/js/vendor_test.exs | 92 ++++++++++++++++++++++++++++-------- 2 files changed, 118 insertions(+), 50 deletions(-) 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/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 From 891ec86c1b0c2b3e1d6e0ce831c9b5ad14e6503b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 9 Sep 2026 16:42:08 +0300 Subject: [PATCH 04/22] refactor(dev): introduce owned session supervision and CSS lifecycle Checkpoint the work-in-progress migration to session-owned ETS tables, compiler runtimes, and stylesheet workers. Serve retained CSS and reconcile compiler-discovered watches. Default-path migration and failure-recovery coverage remain unfinished. --- .reach.exs | 9 +- lib/volt/cache.ex | 17 +- lib/volt/dev.ex | 105 ++++++-- lib/volt/dev/session/state.ex | 30 +++ lib/volt/dev/session/supervisor.ex | 67 +++++ lib/volt/dev/session/tables.ex | 6 + lib/volt/dev/session/watcher.ex | 29 ++ lib/volt/dev/state.ex | 13 + lib/volt/dev_server.ex | 122 ++++++++- lib/volt/dev_server/config.ex | 4 + lib/volt/ets.ex | 6 + lib/volt/hmr/glob_graph.ex | 10 +- lib/volt/hmr/import_graph.ex | 12 +- lib/volt/hmr/module_graph.ex | 24 +- lib/volt/hmr/style_graph.ex | 23 +- lib/volt/tailwind.ex | 13 +- lib/volt/tailwind/compilation.ex | 21 ++ lib/volt/tailwind/loader.ex | 14 +- lib/volt/tailwind/metadata.ex | 28 ++ lib/volt/tailwind/runtime.ex | 52 +++- lib/volt/tailwind/state.ex | 2 +- lib/volt/tailwind/supervisor.ex | 30 +++ lib/volt/tailwind/worker.ex | 68 ++++- lib/volt/watcher.ex | 306 ++++++++++++++++++---- priv/ts/compilers/tailwind.ts | 20 +- test/volt/dev/session/state_test.exs | 44 ++++ test/volt/dev/session/supervisor_test.exs | 135 ++++++++++ test/volt/dev_server/requests_test.exs | 219 ++++++++++++++++ test/volt/dev_test.exs | 79 ++++++ test/volt/tailwind/runtime_test.exs | 102 ++++++++ test/volt/tailwind/worker_test.exs | 24 ++ test/volt/watcher_test.exs | 146 +++++++++++ 32 files changed, 1639 insertions(+), 141 deletions(-) create mode 100644 lib/volt/dev/session/state.ex create mode 100644 lib/volt/dev/session/supervisor.ex create mode 100644 lib/volt/dev/session/tables.ex create mode 100644 lib/volt/dev/session/watcher.ex create mode 100644 lib/volt/dev/state.ex create mode 100644 lib/volt/tailwind/compilation.ex create mode 100644 lib/volt/tailwind/metadata.ex create mode 100644 test/volt/dev/session/state_test.exs create mode 100644 test/volt/dev/session/supervisor_test.exs diff --git a/.reach.exs b/.reach.exs index db833f2..698a221 100644 --- a/.reach.exs +++ b/.reach.exs @@ -36,6 +36,7 @@ orchestrator = [ "Volt.Tailwind", "Volt.Tailwind.Build", "Volt.Watcher", + "Volt.Dev.Session.Watcher", "Volt.Watcher.Ignore", "Volt.Watcher.Path" ] @@ -58,6 +59,7 @@ model = [ "Volt.ChunkGraph.Chunk", "Volt.Config", "Volt.Config.*", + "Volt.Dev.Session.Tables", "Volt.DevServer.CacheEntry", "Volt.DevServer.Config", "Volt.HMR.Message", @@ -70,7 +72,9 @@ model = [ "Volt.MIME", "Volt.Paths", "Volt.Pipeline.Result.Hashes", - "Volt.Tailwind.State" + "Volt.Tailwind.Metadata", + "Volt.Tailwind.State", + "Volt.Tailwind.Compilation" ] logic = [ @@ -130,6 +134,9 @@ infrastructure = [ "Volt.Builder.Publication", "Volt.Cache", "Volt.Dev", + "Volt.Dev.State", + "Volt.Dev.Session.State", + "Volt.Dev.Session.Supervisor", "Volt.Dev.ConsoleForwarder", "Volt.Dev.ConsoleForwarder.Payload", "Volt.ETS", diff --git a/lib/volt/cache.ex b/lib/volt/cache.ex index 6d9139e..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 @@ -22,9 +25,9 @@ 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, session \\ :default) do - key = {session, path} + {table, key} = location(session, path) - case :ets.lookup(@table, key) do + case :ets.lookup(table, key) do [{^key, %{mtime: ^mtime, entry: entry}}] -> entry _ -> nil end @@ -33,9 +36,9 @@ defmodule Volt.Cache do @doc "Look up any cached entry for a file path regardless of mtime." @spec get_file(String.t()) :: entry() | nil def get_file(path, session \\ :default) do - key = {session, path} + {table, key} = location(session, path) - case :ets.lookup(@table, key) do + case :ets.lookup(table, key) do [{^key, %{entry: entry}}] -> entry [] -> nil end @@ -44,14 +47,16 @@ defmodule Volt.Cache do @doc "Store a compiled entry." @spec put(String.t(), integer(), entry()) :: :ok def put(path, mtime, entry, session \\ :default) do - :ets.insert(@table, {{session, path}, %{mtime: mtime, entry: entry}}) + {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, session \\ :default) do - :ets.delete(@table, {session, key}) + {table, key} = location(session, key) + :ets.delete(table, key) :ok end diff --git a/lib/volt/dev.ex b/lib/volt/dev.ex index 735e632..81f5fd9 100644 --- a/lib/volt/dev.ex +++ b/lib/volt/dev.ex @@ -26,6 +26,12 @@ defmodule Volt.Dev do end end + @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 {id, opts} = Keyword.pop(opts, :id, :default) @@ -33,7 +39,38 @@ defmodule Volt.Dev do session = Keyword.get(opts, :session, :default) root = opts |> Keyword.fetch!(:root) |> Path.expand() key = if session == :default, do: {:watcher, id, root}, else: {:session, session} - opts = Keyword.put(opts, :root, root) + opts = normalize_options(opts, root) + + :global.trans({{__MODULE__, key}, self()}, fn -> start_watcher(key, opts) end, [node()]) + end + + defp start_watcher({:session, session} = key, opts) do + name = {:via, Registry, {@registry, key}} + + 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 + + defp start_watcher(key, opts) do name = {:via, Registry, {@registry, key}} case Registry.lookup(@registry, key) do @@ -43,37 +80,75 @@ defmodule Volt.Dev do [] -> opts = Keyword.put(opts, :name, name) - case DynamicSupervisor.start_child(@supervisor, {Volt.Watcher, opts}) do + opts = Keyword.put(opts, :managed_key, key) + spec = Supervisor.child_spec({Volt.Watcher, opts}, restart: :transient) + + case DynamicSupervisor.start_child(@supervisor, spec) do {:error, {:already_started, pid}} -> verify_configuration(pid, opts) result -> result end 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(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 - [] -> :ok - [{pid, _}] -> DynamicSupervisor.terminate_child(@supervisor, pid) - end - |> case do - :ok -> - 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 - error -> - error + [{pid, _}] -> + case DynamicSupervisor.terminate_child(@supervisor, pid) do + {:error, :not_found} -> :ok + result -> result + end end + + Volt.Dev.State.clear(session) + end + + defp normalize_options(opts, root) do + opts + |> 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(pid, opts) do - signature = opts |> Keyword.delete(:name) |> Map.new() + signature = opts |> Keyword.drop([:name, :managed_key]) |> Map.new() if GenServer.call(pid, {:configuration_matches, signature}), do: {:ok, pid}, diff --git a/lib/volt/dev/session/state.ex b/lib/volt/dev/session/state.ex new file mode 100644 index 0000000..a7222a6 --- /dev/null +++ b/lib/volt/dev/session/state.ex @@ -0,0 +1,30 @@ +defmodule Volt.Dev.Session.State do + @moduledoc "Owns the compilation tables for one development-session generation." + + use GenServer + alias Volt.Dev.Session.Tables + + def start_link(opts), do: GenServer.start_link(__MODULE__, :ok, opts) + + def tables(server), do: GenServer.call(server, :tables) + + def attach_stylesheet(server, worker), do: GenServer.call(server, {:attach_stylesheet, worker}) + + @impl true + def init(:ok) do + tables = + Map.new([:cache, :imports, :globs, :styles, :modules], fn kind -> + {kind, :ets.new(kind, [:set, :public, read_concurrency: true])} + end) + + {:ok, struct!(Tables, Map.merge(tables, %{owner: self(), generation: make_ref()}))} + end + + @impl true + def handle_call(:tables, _from, tables), do: {:reply, tables, tables} + + def handle_call({:attach_stylesheet, worker}, _from, tables) do + tables = %{tables | stylesheet_worker: worker} + {:reply, 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..6e24fc4 --- /dev/null +++ b/lib/volt/dev/session/supervisor.ex @@ -0,0 +1,67 @@ +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 + 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 + 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}, + {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( + :configuration_signature, + watcher_opts |> Keyword.delete(:name) |> Map.new() + ) + |> Keyword.put(:tailwind_outdir, nil) + |> 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..ae319e9 --- /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] + 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..e7df315 --- /dev/null +++ b/lib/volt/dev/session/watcher.ex @@ -0,0 +1,29 @@ +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) + + opts = + Keyword.put_new( + opts, + :configuration_signature, + opts + |> Keyword.drop([:name, :state_owner, :tailwind_worker, :tailwind_runtime]) + |> Map.new() + ) + + 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/state.ex b/lib/volt/dev/state.ex new file mode 100644 index 0000000..28b1cba --- /dev/null +++ b/lib/volt/dev/state.ex @@ -0,0 +1,13 @@ +defmodule Volt.Dev.State do + @moduledoc "Cleanup of session-scoped compilation and dependency state." + + def clear(session) do + 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_server.ex b/lib/volt/dev_server.ex index 60f6bae..8716e24 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -61,7 +61,7 @@ defmodule Volt.DevServer do prebundle_vendor(expanded_root, node_modules, plugins, config.resolve_dirs, module_types) watcher_opts = - if server_config.watch do + if server_config.watch and is_nil(Keyword.get(opts, :session_supervisor)) do watch_dirs = if tailwind_root && server_config.watch_dirs == [] do [Volt.Paths.lib()] @@ -112,20 +112,107 @@ 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: Keyword.get(opts, :session, :default), 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, %{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] -> + 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 + ) + 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/ws"} = conn, config) do conn |> WebSockAdapter.upgrade(Volt.HMR.Socket, [session: config.session], @@ -225,7 +312,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, config.session) + update_module_graph( + mod_url, + id, + id, + code, + source, + content_type, + config.tables || config.session + ) send_compiled(conn, code, result.sourcemap, content_type) @@ -290,7 +385,7 @@ defmodule Volt.DevServer do 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, config.session) do + case Volt.Cache.get(cache_key, mtime, config.tables || config.session) do %{code: code, sourcemap: sourcemap} -> send_compiled(conn, code, sourcemap, content_type) @@ -323,9 +418,20 @@ 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, config.session) - Volt.HMR.ImportGraph.update_from_compiled(file_path, result.code, config.session) - Volt.HMR.StyleDependencies.update_from_compile(file_path, source, result, config.session) + 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) @@ -339,7 +445,7 @@ defmodule Volt.DevServer do code, source, content_type, - config.session + config.tables || config.session ) entry = %Volt.DevServer.CacheEntry{ @@ -350,7 +456,7 @@ defmodule Volt.DevServer do content_type: content_type } - Volt.Cache.put(cache_key, mtime, entry, config.session) + Volt.Cache.put(cache_key, mtime, entry, config.tables || config.session) send_compiled(conn, code, result.sourcemap, content_type) {:error, errors} -> diff --git a/lib/volt/dev_server/config.ex b/lib/volt/dev_server/config.ex index 520b704..3b320ff 100644 --- a/lib/volt/dev_server/config.ex +++ b/lib/volt/dev_server/config.ex @@ -16,5 +16,9 @@ defmodule Volt.DevServer.Config do 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 9743291..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]) diff --git a/lib/volt/hmr/glob_graph.ex b/lib/volt/hmr/glob_graph.ex index 6c00f66..baa6675 100644 --- a/lib/volt/hmr/glob_graph.ex +++ b/lib/volt/hmr/glob_graph.ex @@ -3,12 +3,14 @@ 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, session \\ :default), - do: Volt.ETS.put(@table, {{session, importer}, globs}) + 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, session \\ :default) do @@ -31,14 +33,14 @@ defmodule Volt.HMR.GlobGraph do acc end, [], - @table + table(session) ) end - def remove(path, session \\ :default), do: Volt.ETS.delete(@table, {session, 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) + 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 0e91d67..7a15590 100644 --- a/lib/volt/hmr/import_graph.ex +++ b/lib/volt/hmr/import_graph.ex @@ -9,6 +9,8 @@ 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) @@ -16,7 +18,7 @@ defmodule Volt.HMR.ImportGraph do @doc "Update the imports for a file path." @spec update(String.t(), [String.t()]) :: :ok def update(path, imports, session \\ :default) do - Volt.ETS.put(@table, {{session, path}, Enum.uniq(imports)}) + Volt.ETS.put(table(session), {{session, path}, Enum.uniq(imports)}) end @doc "Update imports from compiled code." @@ -34,7 +36,7 @@ defmodule Volt.HMR.ImportGraph do @doc "Get the imports for a file path." @spec imports_of(String.t()) :: [String.t()] def imports_of(path, session \\ :default) do - case :ets.lookup(@table, {session, path}) do + case :ets.lookup(table(session), {session, path}) do [{_, imports}] -> imports [] -> [] end @@ -67,18 +69,18 @@ defmodule Volt.HMR.ImportGraph do acc end, [], - @table + table(session) ) end @doc "Remove a file from the graph." @spec remove(String.t()) :: :ok - def remove(path, session \\ :default), do: Volt.ETS.delete(@table, {session, 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) + 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 6e658d4..fbadad8 100644 --- a/lib/volt/hmr/module_graph.ex +++ b/lib/volt/hmr/module_graph.ex @@ -17,13 +17,15 @@ defmodule Volt.HMR.ModuleGraph do last_invalidated_at: nil end + defp table(session), do: Volt.ETS.session_table(session, :modules, @table) + def create_table, do: ETS.create_named_set(@table) def update_module(url, id, file, imports, opts \\ []) do 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, {:url, old.url}}) + 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 = @@ -83,15 +85,15 @@ defmodule Volt.HMR.ModuleGraph do end end) - ETS.delete(@table, {session, {:url, node.url}}) - ETS.delete(@table, {session, {:id, node.id}}) + ETS.delete(table(session), {session, {:url, node.url}}) + ETS.delete(table(session), {session, {:id, node.id}}) end) - ETS.delete(@table, {session, {:file, file}}) + ETS.delete(table(session), {session, {:file, file}}) end def clear, do: ETS.clear(@table) - def clear_session(session), do: ETS.clear_session(@table, session) + def clear_session(session), do: ETS.clear_session(table(session), session) defp nodes(session) do :ets.foldl( @@ -100,20 +102,20 @@ defmodule Volt.HMR.ModuleGraph do _, acc -> acc end, [], - @table + table(session) ) end defp put_node(node, session) do ids = lookup({:file, node.file}, session) || MapSet.new() - ETS.put(@table, {{session, {:url, node.url}}, node.id}) - ETS.put(@table, {{session, {:id, node.id}}, node}) - ETS.put(@table, {{session, {:file, node.file}}, MapSet.put(ids, node.id)}) + 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 unlink_file(node, session) do ids = lookup({:file, node.file}, session) || MapSet.new() - ETS.put(@table, {{session, {:file, node.file}}, MapSet.delete(ids, node.id)}) + ETS.put(table(session), {{session, {:file, node.file}}, MapSet.delete(ids, node.id)}) end defp unlink_imports(nil, _session), do: :ok @@ -127,7 +129,7 @@ defmodule Volt.HMR.ModuleGraph do end defp lookup(key, session) do - case :ets.lookup(@table, {session, key}) do + case :ets.lookup(table(session), {session, key}) do [{_, value}] -> value [] -> nil end diff --git a/lib/volt/hmr/style_graph.ex b/lib/volt/hmr/style_graph.ex index 75f0154..713189c 100644 --- a/lib/volt/hmr/style_graph.ex +++ b/lib/volt/hmr/style_graph.ex @@ -10,6 +10,8 @@ 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) @@ -25,7 +27,7 @@ defmodule Volt.HMR.StyleGraph do put_dependents(dependency, dependents, session) end) - Volt.ETS.put(@table, {{session, {:dependencies, path}}, dependencies}) + Volt.ETS.put(table(session), {{session, {:dependencies, path}}, dependencies}) Enum.each(dependencies, fn dependency -> dependents = dependency |> dependents_of(session) |> MapSet.new() |> MapSet.put(path) @@ -37,7 +39,8 @@ 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, session \\ :default), do: lookup_set({session, {: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()] @@ -48,8 +51,8 @@ defmodule Volt.HMR.StyleGraph do @spec remove(String.t()) :: :ok def remove(path, session \\ :default) do update(path, [], session) - Volt.ETS.delete(@table, {session, {:dependencies, path}}) - Volt.ETS.delete(@table, {session, {:dependents, path}}) + Volt.ETS.delete(table(session), {session, {:dependencies, path}}) + Volt.ETS.delete(table(session), {session, {:dependents, path}}) :ok end @@ -58,7 +61,7 @@ defmodule Volt.HMR.StyleGraph do def clear, do: Volt.ETS.clear(@table) @doc "Clear stylesheet edges belonging to one session." - def clear_session(session), do: Volt.ETS.clear_session(@table, session) + def clear_session(session), do: Volt.ETS.clear_session(table(session), session) defp walk_dependents(path, seen, session) do path @@ -70,18 +73,18 @@ defmodule Volt.HMR.StyleGraph do |> Enum.uniq() end - defp dependents_of(path, session), do: lookup_set({session, {:dependents, path}}) + defp dependents_of(path, session), do: lookup_set({session, {:dependents, path}}, session) defp put_dependents(path, dependents, session) do if MapSet.size(dependents) == 0 do - Volt.ETS.delete(@table, {session, {:dependents, path}}) + Volt.ETS.delete(table(session), {session, {:dependents, path}}) else - Volt.ETS.put(@table, {{session, {: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/tailwind.ex b/lib/volt/tailwind.ex index a55ecb3..0166f6c 100644 --- a/lib/volt/tailwind.ex +++ b/lib/volt/tailwind.ex @@ -24,7 +24,18 @@ defmodule Volt.Tailwind do end defp worker(opts) do + case Keyword.fetch(opts, :worker) do + {:ok, worker} -> worker + :error -> registered_worker(opts) + end + end + + defp registered_worker(opts) do key = Keyword.get(opts, :key, @default_key) - Volt.Tailwind.Supervisor.worker(key, sources: Keyword.get(opts, :sources, [])) + + Volt.Tailwind.Supervisor.worker(key, + sources: Keyword.get(opts, :sources, []), + runtime: Keyword.get(opts, :runtime) + ) end 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/loader.ex b/lib/volt/tailwind/loader.ex index 59c80f3..96dbb1f 100644 --- a/lib/volt/tailwind/loader.ex +++ b/lib/volt/tailwind/loader.ex @@ -47,29 +47,33 @@ defmodule Volt.Tailwind.Loader do 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/runtime.ex b/lib/volt/tailwind/runtime.ex index 2ce1337..6341ef2 100644 --- a/lib/volt/tailwind/runtime.ex +++ b/lib/volt/tailwind/runtime.ex @@ -5,13 +5,18 @@ defmodule Volt.Tailwind.Runtime do @name __MODULE__ - def start_link(_opts) do - GenServer.start_link(__MODULE__, :ok, name: @name) + 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) do - GenServer.call(@name, {:compile, css, candidates, css_base}, :infinity) + def call(css, candidates, css_base, server \\ @name) do + GenServer.call(server, {:compile, css, candidates, css_base}, :infinity) + end + + @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." @@ -30,20 +35,49 @@ defmodule Volt.Tailwind.Runtime do end @impl true - def init(:ok), do: {:ok, nil} + def init(:ok) do + Process.flag(:trap_exit, true) + {:ok, nil} + end @impl true + def handle_call({:compile_metadata, css, candidates, css_base}, _from, runtime) do + runtime = if runtime && Process.alive?(runtime.pid), do: runtime, else: start_runtime() + + case Volt.JS.Runtime.call(runtime, "compileTailwindCss", [ + css, + candidates, + css_base, + nil, + true + ]) do + {:ok, result} -> {:reply, {:ok, Volt.Tailwind.Metadata.decode(result)}, runtime} + {:error, _} = error -> {:reply, error, runtime} + end + end + def handle_call({:compile, css, candidates, css_base}, _from, runtime) do - runtime = runtime || start_runtime() - result = Volt.JS.Runtime.call(runtime, "compileTailwindCss", [css, candidates, css_base]) - {:reply, normalize_result(result), runtime} + runtime = if runtime && Process.alive?(runtime.pid), do: runtime, else: start_runtime() + + try do + result = Volt.JS.Runtime.call(runtime, "compileTailwindCss", [css, candidates, css_base]) + {:reply, normalize_result(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 - Volt.JS.Runtime.stop(runtime) + if Process.alive?(runtime.pid), do: Volt.JS.Runtime.stop(runtime) :ok end diff --git a/lib/volt/tailwind/state.ex b/lib/volt/tailwind/state.ex index 2d7239d..e4d1bbf 100644 --- a/lib/volt/tailwind/state.ex +++ b/lib/volt/tailwind/state.ex @@ -2,5 +2,5 @@ defmodule Volt.Tailwind.State do @moduledoc false @enforce_keys [:key, :sources] - defstruct [:key, :scanner, :last_css, :sources] + defstruct [:key, :scanner, :last_css, :sources, :compilation, :runtime, dependencies: []] end diff --git a/lib/volt/tailwind/supervisor.ex b/lib/volt/tailwind/supervisor.ex index 4c72565..f205b81 100644 --- a/lib/volt/tailwind/supervisor.ex +++ b/lib/volt/tailwind/supervisor.ex @@ -4,6 +4,36 @@ defmodule Volt.Tailwind.Supervisor do @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}} diff --git a/lib/volt/tailwind/worker.ex b/lib/volt/tailwind/worker.ex index 3184ab4..3f358a7 100644 --- a/lib/volt/tailwind/worker.ex +++ b/lib/volt/tailwind/worker.ex @@ -14,21 +14,68 @@ defmodule Volt.Tailwind.Worker do {:ok, %State{ key: Keyword.fetch!(opts, :key), + runtime: Keyword.get(opts, :runtime), scanner: nil, last_css: nil, 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 = opts[:sources] || state.sources - scanner = build_scanner(sources) + compilation = Volt.Tailwind.Compilation.new(opts) + runtime = state.runtime || Volt.Tailwind.Supervisor.runtime(state.key) + + with {:ok, metadata} <- + Volt.Tailwind.Runtime.compile_metadata(compilation.css, [], compilation.base, runtime) do + automatic = + case metadata.root do + :none -> + [] + + :automatic -> + if sources == [], do: [%{base: compilation.base, pattern: "**/*"}], else: [] + + source -> + [source] + end + + sources = Enum.uniq(automatic ++ sources ++ metadata.sources) + scanner = build_scanner(sources) + finish_build(state, compilation, scanner, sources, metadata.dependencies) + else + {:error, _} = error -> {:reply, error, state} + end + end - case compile_css(opts[:css], scan(scanner), opts[:css_base]) do + defp finish_build(state, compilation, scanner, sources, dependencies) do + case compile_css(compilation.css, scan(scanner), compilation.base, state) do {:ok, css} -> - css = maybe_minify(css, Keyword.get(opts, :minify, false)) - {:reply, {:ok, css}, %{state | scanner: scanner, last_css: css, sources: sources}} + css = maybe_minify(css, compilation.minify) + + {:reply, {:ok, css}, + %{ + state + | scanner: scanner, + last_css: css, + sources: sources, + compilation: compilation, + dependencies: dependencies + }} {:error, _reason} = error -> {:reply, error, state} @@ -56,11 +103,13 @@ defmodule Volt.Tailwind.Worker do end defp compile_rebuilt_css(state, scanner, opts) do - case compile_css(opts[:css], Oxide.scan(scanner), opts[:css_base]) do + compilation = Volt.Tailwind.Compilation.update(state.compilation, opts) + + case compile_css(compilation.css, Oxide.scan(scanner), compilation.base, state) do {:ok, css} -> - css = maybe_minify(css, Keyword.get(opts, :minify, false)) + css = maybe_minify(css, compilation.minify) reply = if css == state.last_css, do: :unchanged, else: {:ok, css} - {:reply, reply, %{state | last_css: css}} + {:reply, reply, %{state | last_css: css, compilation: compilation}} {:error, _reason} = error -> {:reply, error, state} @@ -86,8 +135,9 @@ defmodule Volt.Tailwind.Worker do defp changed_file(map), do: struct!(Oxide.Changed, map) - defp compile_css(css, candidates, css_base) do - Volt.Tailwind.Runtime.call(css, candidates, Path.expand(css_base || File.cwd!())) + defp compile_css(css, candidates, css_base, state) do + runtime = state.runtime || Volt.Tailwind.Supervisor.runtime(state.key) + Volt.Tailwind.Runtime.call(css, candidates, css_base, runtime) end defp maybe_minify(css, false), do: css diff --git a/lib/volt/watcher.ex b/lib/volt/watcher.ex index 957ab2f..dd50a00 100644 --- a/lib/volt/watcher.ex +++ b/lib/volt/watcher.ex @@ -48,13 +48,19 @@ defmodule Volt.Watcher do :root, :config, :configuration_signature, + :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: [], watch_ignored: [] @@ -69,13 +75,26 @@ 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 = @@ -84,6 +103,9 @@ defmodule Volt.Watcher do :root, :name, :session, + :owner, + :managed_key, + :tables, :watch_dirs, :reload_dirs, :watch_ignored, @@ -95,7 +117,18 @@ defmodule Volt.Watcher do ]) |> 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) @@ -110,16 +143,26 @@ defmodule Volt.Watcher do 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, - configuration_signature: opts |> Keyword.delete(:name) |> Map.new(), + owner_monitor: owner_monitor, + tables: Keyword.get(opts, :tables), + managed_key: Keyword.get(opts, :managed_key), + configuration_signature: + Keyword.get( + opts, + :configuration_signature, + opts |> Keyword.drop([:name, :managed_key]) |> Map.new() + ), 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, watch_ignored: watch_ignored @@ -131,13 +174,57 @@ defmodule Volt.Watcher do config[:tailwind_css], tailwind_outdir, tailwind_key, - tailwind_name + 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.reject(&(&1 in state.base_watch_dirs)) |> Enum.filter(&File.dir?/1) + {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 refresh_tailwind_inputs(state), do: state + @impl true def handle_call({:configuration_matches, signature}, _from, state) do {:reply, state.configuration_signature == signature, state} @@ -145,8 +232,8 @@ defmodule Volt.Watcher do defp watcher_sources(dirs), do: Enum.map(dirs, &%{base: &1, pattern: "**/*"}) - defp initial_tailwind_build(sources, css_path, outdir, key, name) do - case build_tailwind(sources, css_path, outdir, key, name) do + 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") @@ -155,14 +242,18 @@ defmodule Volt.Watcher do end end - defp build_tailwind(sources, css_path, outdir, key, name) do + 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( - key: key, - sources: sources, - css: css_input, - css_base: css_base + worker_opts ++ + [ + sources: sources, + css: css_input, + css_base: css_base + ] ) do Volt.Tailwind.Artifact.write(outdir, name, css) {:ok, css} @@ -189,6 +280,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} @@ -208,7 +306,10 @@ defmodule Volt.Watcher do reload_path?(path, state) -> handle_reload_change(path, state) - {:noreply, state} + {:noreply, maybe_schedule_tailwind(state, path)} + + tailwind_source?(path, state) -> + {:noreply, maybe_schedule_tailwind(state, path)} true -> {:noreply, state} @@ -228,14 +329,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, state} + 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 @@ -277,20 +396,37 @@ 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, state.session), else: [] - old_entry = Volt.Cache.get_file(path, state.session) - Volt.Cache.evict_file(path, state.session) - Volt.HMR.ModuleGraph.invalidate_file(path, System.system_time(:millisecond), state.session) + 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, state.session) - Volt.HMR.ImportGraph.update_from_compiled(path, result.code, state.session) + Volt.HMR.GlobGraph.update_from_source(path, source, state.tables || state.session) - Volt.HMR.StyleDependencies.update_from_compile(path, source, result, state.session) + Volt.HMR.ImportGraph.update_from_compiled( + path, + result.code, + state.tables || state.session + ) + + 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) @@ -302,10 +438,10 @@ defmodule Volt.Watcher do end {:error, reason} when reason in [:enoent, :eacces, :eperm] -> - Volt.HMR.ImportGraph.remove(path, state.session) - Volt.HMR.GlobGraph.remove(path, state.session) - if css?, do: Volt.HMR.StyleGraph.remove(path, state.session) - Volt.HMR.ModuleGraph.remove_file(path, state.session) + 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) @@ -318,17 +454,27 @@ defmodule Volt.Watcher do defp handle_css_change(path, state) do relative = Path.relative_to(path, state.root) - css_dependents = StyleGraph.dependents(path, state.session) + css_dependents = StyleGraph.dependents(path, state.tables || state.session) tailwind_input? = tailwind_input?(path, state) - Volt.Cache.evict_file(path, state.session) - Volt.HMR.ModuleGraph.invalidate_file(path, System.system_time(:millisecond), 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 + ) if File.regular?(path) do source = File.read!(path) - StyleGraph.update(path, Volt.CSS.Dependencies.resolve(source, path), state.session) + + StyleGraph.update( + path, + Volt.CSS.Dependencies.resolve(source, path), + state.tables || state.session + ) else - StyleGraph.remove(path, state.session) + StyleGraph.remove(path, state.tables || state.session) end if Volt.Path.inside?(path, state.root) and not tailwind_input? do @@ -354,7 +500,7 @@ defmodule Volt.Watcher do dependents |> Enum.uniq() |> Enum.each(fn importer -> - Volt.Cache.evict_file(importer, state.session) + Volt.Cache.evict_file(importer, state.tables || state.session) Volt.HMR.ModuleGraph.invalidate_file( importer, @@ -374,10 +520,16 @@ defmodule Volt.Watcher do defp handle_asset_change(path, state) do relative = Path.relative_to(path, state.root) - css_dependents = Volt.HMR.StyleGraph.dependents(path, state.session) + css_dependents = Volt.HMR.StyleGraph.dependents(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 + ) - Volt.Cache.evict_file(path, state.session) - Volt.HMR.ModuleGraph.invalidate_file(path, System.system_time(:millisecond), state.session) HMR.broadcast(:update, %{path: relative, changes: [:full]}, session: state.session) broadcast_css_dependents(css_dependents, state) broadcast_glob_dependents(path, state) @@ -385,10 +537,10 @@ defmodule Volt.Watcher do defp broadcast_glob_dependents(path, state) do path - |> Volt.HMR.GlobGraph.dependents(state.session) + |> Volt.HMR.GlobGraph.dependents(state.tables || state.session) |> Enum.reject(&(&1 == path)) |> Enum.each(fn importer -> - Volt.Cache.evict_file(importer, state.session) + 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) @@ -410,7 +562,7 @@ defmodule Volt.Watcher do end end - case Volt.HMR.Boundary.find_boundary(path, read_source, state.session) 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, state.root) @@ -433,24 +585,29 @@ defmodule Volt.Watcher do end defp handle_tailwind_rebuild(_changed_paths, true, state) do + previous = previous_stylesheet(state) + case build_tailwind( - state.config.tailwind_sources, + state.config.tailwind_configured_sources, state.config[:tailwind_css], state.tailwind_outdir, state.config.tailwind_key, - state.config.tailwind_name + state.config.tailwind_name, + state.config[:tailwind_runtime], + state.config[:tailwind_worker] ) do {:ok, css} -> - HMR.broadcast(:update, %{path: state.config.tailwind_url, changes: [:style]}, - session: state.session - ) + 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)}, - session: state.session - ) + tailwind_error(reason, state.session) end end @@ -461,12 +618,21 @@ defmodule Volt.Watcher do %{file: path, extension: ext} end) + worker_opts = + case state.config[:tailwind_worker] do + nil -> [key: state.config.tailwind_key] + worker -> [worker: worker] + end + with {:ok, {css_input, css_base}} <- read_tailwind_css(state.config[:tailwind_css]), {:ok, css} <- - Volt.Tailwind.rebuild(changed, - key: state.config.tailwind_key, - css: css_input, - css_base: css_base + 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) @@ -475,17 +641,32 @@ defmodule Volt.Watcher do ) Logger.debug("[Volt] Tailwind rebuilt (#{byte_size(css)} bytes)") + :ok else :unchanged -> :ok {:error, reason} -> - HMR.broadcast(:error, %{path: "tailwind", reason: inspect(reason)}, - session: state.session - ) + 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 @@ -502,6 +683,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 @@ -520,7 +712,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.State.clear(state.session) + if state.managed_key, do: Registry.unregister(Volt.Dev.WatcherRegistry, state.managed_key) :ok end diff --git a/priv/ts/compilers/tailwind.ts b/priv/ts/compilers/tailwind.ts index 08dc9b5..fec7e7e 100644 --- a/priv/ts/compilers/tailwind.ts +++ b/priv/ts/compilers/tailwind.ts @@ -2,6 +2,7 @@ type BeamModuleSpec = { path: string base: string code: string + dependencies: string[] format: 'cjs' | 'json' } @@ -79,8 +80,10 @@ async function compileTailwindCss( inputCss: string | null, candidates: string[] | null, base: string | null, - scan: { base: string; sources: Source[] } | null = null + scan: { base: string; sources: Source[] } | null = null, + metadata = false ) { + const dependencies = new Set() const moduleCache = new Map() const rootBase = normalizeBase(base, TAILWIND_DEFAULT_BASE) const css = inputCss === null ? '@import "tailwindcss";' : inputCss @@ -122,12 +125,14 @@ async function compileTailwindCss( } } - return Beam.callSync( + const stylesheet = Beam.callSync( 'tailwind.load_stylesheet', id, normalizeBase(currentBase, rootBase), rootBase ) as { path: string; base: string; content: string } + dependencies.add(stylesheet.path) + return stylesheet }, loadModule: async (id, currentBase, type) => { const spec = Beam.callSync( @@ -137,6 +142,7 @@ async function compileTailwindCss( type ?? 'plugin' ) as BeamModuleSpec + for (const dependency of spec.dependencies) dependencies.add(dependency) const mod = requireResolvedModule(spec, moduleCache) return { path: spec.path, module: unwrapModule(mod), base: spec.base } } @@ -155,7 +161,15 @@ async function compileTailwindCss( ...compiler.sources ]) as string[] } - return compiler.build(candidates ?? []) + 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) { 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/requests_test.exs b/test/volt/dev_server/requests_test.exs index 430e04d..31ca1ac 100644 --- a/test/volt/dev_server/requests_test.exs +++ b/test/volt/dev_server/requests_test.exs @@ -1,6 +1,225 @@ defmodule Volt.DevServer.RequestsTest do use Volt.TestSupport.DevServerCase + @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 c912347..580d558 100644 --- a/test/volt/dev_test.exs +++ b/test/volt/dev_test.exs @@ -1,6 +1,85 @@ defmodule Volt.DevTest do use ExUnit.Case, async: false + @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 -> + Volt.Dev.stop(a) + Volt.Dev.stop(b) + end) + + 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) + + 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() diff --git a/test/volt/tailwind/runtime_test.exs b/test/volt/tailwind/runtime_test.exs index bcb86c5..4067fd4 100644 --- a/test/volt/tailwind/runtime_test.exs +++ b/test/volt/tailwind/runtime_test.exs @@ -1,6 +1,108 @@ 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 + + test "compiler death during a call returns an error and permits recovery" 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 -> Volt.Tailwind.Runtime.call("", [], 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 + + 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) diff --git a/test/volt/tailwind/worker_test.exs b/test/volt/tailwind/worker_test.exs index 6d30d05..6a63b64 100644 --- a/test/volt/tailwind/worker_test.exs +++ b/test/volt/tailwind/worker_test.exs @@ -3,6 +3,30 @@ defmodule Volt.Tailwind.WorkerTest do @moduletag :tmp_dir + 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") diff --git a/test/volt/watcher_test.exs b/test/volt/watcher_test.exs index 3aeb2e4..9a6e273 100644 --- a/test/volt/watcher_test.exs +++ b/test/volt/watcher_test.exs @@ -12,6 +12,152 @@ defmodule Volt.WatcherTest do {:ok, watch_dir: watch_dir} 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_receive {:volt_hmr, :error, _} + refute_received {:volt_hmr, :update, _} + File.write!(css, "@tailwind utilities; .changed { color: red }") + recovered = %{pending | tailwind_full?: true, config: state.config} + 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) From 56980c8454e567ebb7e72e2af63db6f60c80554b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 10 Sep 2026 10:50:00 +0300 Subject: [PATCH 05/22] refactor(dev): retain Tailwind contexts and unify managed startup Track failed resolution candidates for rebuild recovery, migrate default managed and CLI startup to session subtrees, and add real filesystem isolation coverage. Development groundwork checkpoint; lifecycle lookup races and combined CLI/Plug compatibility remain to be verified before release. --- .reach.exs | 3 + lib/mix/tasks/volt/dev.ex | 48 +++----- lib/volt/dev.ex | 32 ++--- lib/volt/dev/session/supervisor.ex | 2 +- lib/volt/dev_server.ex | 15 ++- lib/volt/tailwind/compile_error.ex | 4 + lib/volt/tailwind/context.ex | 5 + lib/volt/tailwind/loader.ex | 11 +- lib/volt/tailwind/resolve_error.ex | 4 + lib/volt/tailwind/resolver.ex | 31 ++++- lib/volt/tailwind/runtime.ex | 74 ++++++++++-- lib/volt/tailwind/state.ex | 12 +- lib/volt/tailwind/worker.ex | 159 +++++++++++++++++++------ lib/volt/watcher.ex | 24 +++- lib/volt/watcher/ignore.ex | 8 +- priv/ts/compilers/tailwind.ts | 151 +++++++++++++++-------- test/volt/dev_server/basic_test.exs | 8 +- test/volt/dev_test.exs | 55 +++++---- test/volt/integration/session_test.exs | 91 ++++++++++++++ test/volt/tailwind/runtime_test.exs | 103 +++++++++++----- test/volt/tailwind/worker_test.exs | 51 ++++++++ test/volt/watcher_test.exs | 152 ++++++++++++++++++++++- 22 files changed, 817 insertions(+), 226 deletions(-) create mode 100644 lib/volt/tailwind/compile_error.ex create mode 100644 lib/volt/tailwind/context.ex create mode 100644 lib/volt/tailwind/resolve_error.ex create mode 100644 test/volt/integration/session_test.exs diff --git a/.reach.exs b/.reach.exs index 698a221..9ac49aa 100644 --- a/.reach.exs +++ b/.reach.exs @@ -72,6 +72,9 @@ model = [ "Volt.MIME", "Volt.Paths", "Volt.Pipeline.Result.Hashes", + "Volt.Tailwind.CompileError", + "Volt.Tailwind.ResolveError", + "Volt.Tailwind.Context", "Volt.Tailwind.Metadata", "Volt.Tailwind.State", "Volt.Tailwind.Compilation" diff --git a/lib/mix/tasks/volt/dev.ex b/lib/mix/tasks/volt/dev.ex index 0427ca2..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 @@ -58,8 +59,11 @@ defmodule Mix.Tasks.Volt.Dev do |> Volt.Config.Tailwind.new() tailwind? = - Keyword.get(parsed, :tailwind) || - (Volt.Config.Tailwind.enabled?(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) @@ -87,11 +91,8 @@ defmodule Mix.Tasks.Volt.Dev do tailwind_css = tailwind_root.css - if tailwind? do - initial_build(tailwind_root, parsed, profile) - end - opts = [ + id: profile || :default, root: root, watch_dirs: watch_dirs, reload_dirs: reload_dirs, @@ -102,11 +103,11 @@ defmodule Mix.Tasks.Volt.Dev do tailwind_name: tailwind_root.name, tailwind_sources: tailwind_root.sources, tailwind_url: tailwind_root.dev_url, - tailwind_outdir: Keyword.get(parsed, :tailwind_outdir, Paths.static_css()), + 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]}...") @@ -123,32 +124,13 @@ defmodule Mix.Tasks.Volt.Dev do end unless iex_running?() do - Process.sleep(:infinity) - end - end + session = Dev.session_identity(opts) - defp initial_build(root, parsed, profile) do - css_input = if root.css, do: File.read!(root.css) - css_base = if root.css, do: Path.dirname(root.css), else: File.cwd!() - key = tailwind_key(profile, root) - - case Volt.Tailwind.build( - key: key, - sources: root.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, "#{root.name}.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)}") + try do + Process.sleep(:infinity) + after + Dev.stop(session) + end end end diff --git a/lib/volt/dev.ex b/lib/volt/dev.ex index 81f5fd9..686f64b 100644 --- a/lib/volt/dev.ex +++ b/lib/volt/dev.ex @@ -34,12 +34,10 @@ defmodule Volt.Dev do @doc "Start or reuse a managed watcher, rejecting conflicting configuration." def start(opts) do - {id, opts} = Keyword.pop(opts, :id, :default) - - session = Keyword.get(opts, :session, :default) + session = session_identity(opts) root = opts |> Keyword.fetch!(:root) |> Path.expand() - key = if session == :default, do: {:watcher, id, root}, else: {:session, session} - opts = normalize_options(opts, root) + key = {:session, session} + opts = normalize_options(Keyword.put(opts, :session, session), root) :global.trans({{__MODULE__, key}, self()}, fn -> start_watcher(key, opts) end, [node()]) end @@ -70,23 +68,14 @@ defmodule Volt.Dev do end end - defp start_watcher(key, opts) do - name = {:via, Registry, {@registry, key}} + @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))} - case Registry.lookup(@registry, key) do - [{pid, _}] -> - verify_configuration(pid, opts) - - [] -> - opts = Keyword.put(opts, :name, name) - - opts = Keyword.put(opts, :managed_key, key) - spec = Supervisor.child_spec({Volt.Watcher, opts}, restart: :transient) - - case DynamicSupervisor.start_child(@supervisor, spec) do - {:error, {:already_started, pid}} -> verify_configuration(pid, opts) - result -> result - end + session -> + session end end @@ -141,6 +130,7 @@ defmodule Volt.Dev do 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)) diff --git a/lib/volt/dev/session/supervisor.ex b/lib/volt/dev/session/supervisor.ex index 6e24fc4..40a2d16 100644 --- a/lib/volt/dev/session/supervisor.ex +++ b/lib/volt/dev/session/supervisor.ex @@ -52,7 +52,7 @@ defmodule Volt.Dev.Session.Supervisor do :configuration_signature, watcher_opts |> Keyword.delete(:name) |> Map.new() ) - |> Keyword.put(:tailwind_outdir, nil) + |> 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)} diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index 8716e24..999c495 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -60,6 +60,17 @@ defmodule Volt.DevServer do prebundle_vendor(expanded_root, node_modules, plugins, config.resolve_dirs, module_types) + 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 = @@ -71,7 +82,7 @@ defmodule Volt.DevServer do [ id: profile || :default, - session: Keyword.get(opts, :session, :default), + session: session, root: expanded_root, watch_dirs: watch_dirs, reload_dirs: server_config.reload_dirs, @@ -116,7 +127,7 @@ defmodule Volt.DevServer do stylesheet_source: tailwind_root && tailwind_root.css, session_supervisor: Keyword.get(opts, :session_supervisor), tables: Keyword.get(opts, :tables), - session: Keyword.get(opts, :session, :default), + session: session, watcher_opts: watcher_opts } 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 96dbb1f..8e550ab 100644 --- a/lib/volt/tailwind/loader.ex +++ b/lib/volt/tailwind/loader.ex @@ -25,14 +25,21 @@ defmodule Volt.Tailwind.Loader do |> Oxide.scan() end, "tailwind.load_stylesheet" => fn [id, base, output_base] -> - load_stylesheet(id, base, output_base, runtime_node_modules) + 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 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) 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 index 6341ef2..13d8926 100644 --- a/lib/volt/tailwind/runtime.ex +++ b/lib/volt/tailwind/runtime.ex @@ -14,6 +14,15 @@ defmodule Volt.Tailwind.Runtime 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) @@ -41,27 +50,62 @@ defmodule Volt.Tailwind.Runtime do end @impl true - def handle_call({:compile_metadata, css, candidates, css_base}, _from, runtime) do + def handle_call({:prepare_context, css, base}, _from, runtime) do runtime = if runtime && Process.alive?(runtime.pid), do: runtime, else: start_runtime() - case Volt.JS.Runtime.call(runtime, "compileTailwindCss", [ - css, - candidates, - css_base, - nil, - true - ]) do - {:ok, result} -> {:reply, {:ok, Volt.Tailwind.Metadata.decode(result)}, runtime} - {:error, _} = error -> {:reply, error, 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", [css, candidates, css_base]) - {:reply, normalize_result(result), runtime} + 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) @@ -81,6 +125,12 @@ defmodule Volt.Tailwind.Runtime do :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(), diff --git a/lib/volt/tailwind/state.ex b/lib/volt/tailwind/state.ex index e4d1bbf..fee6ac6 100644 --- a/lib/volt/tailwind/state.ex +++ b/lib/volt/tailwind/state.ex @@ -2,5 +2,15 @@ defmodule Volt.Tailwind.State do @moduledoc false @enforce_keys [:key, :sources] - defstruct [:key, :scanner, :last_css, :sources, :compilation, :runtime, dependencies: []] + defstruct [ + :key, + :scanner, + :last_css, + :sources, + :compilation, + :runtime, + :context, + configured_sources: [], + dependencies: [] + ] end diff --git a/lib/volt/tailwind/worker.ex b/lib/volt/tailwind/worker.ex index 3f358a7..4201b96 100644 --- a/lib/volt/tailwind/worker.ex +++ b/lib/volt/tailwind/worker.ex @@ -3,7 +3,7 @@ defmodule Volt.Tailwind.Worker do use GenServer - alias Volt.Tailwind.State + alias Volt.Tailwind.{State, Runtime} def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: Keyword.fetch!(opts, :name)) @@ -11,12 +11,15 @@ defmodule Volt.Tailwind.Worker do @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 @@ -36,41 +39,68 @@ defmodule Volt.Tailwind.Worker do end def handle_call({:build, opts}, _from, state) do - sources = opts[:sources] || state.sources + 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) - with {:ok, metadata} <- - Volt.Tailwind.Runtime.compile_metadata(compilation.css, [], compilation.base, runtime) do - automatic = - case metadata.root do - :none -> - [] + 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)}} - :automatic -> - if sources == [], do: [%{base: compilation.base, pattern: "**/*"}], else: [] + {:error, _} = error -> + {:reply, error, state} + end + end - source -> - [source] - 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) - sources = Enum.uniq(automatic ++ sources ++ metadata.sources) - scanner = build_scanner(sources) - finish_build(state, compilation, scanner, sources, metadata.dependencies) - else - {:error, _} = error -> {:reply, error, state} + nil -> + {:reply, {:error, :no_scanner}, state} + + scanner -> + rebuild_css(state, scanner, changed_files, opts) end end - defp finish_build(state, compilation, scanner, sources, dependencies) do - case compile_css(compilation.css, scan(scanner), compilation.base, state) do + 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 - | scanner: scanner, + | context: context, + scanner: scanner, last_css: css, sources: sources, compilation: compilation, @@ -78,24 +108,19 @@ defmodule Volt.Tailwind.Worker do }} {:error, _reason} = error -> + Runtime.release_context(context, runtime) {:reply, error, state} end end - def handle_call({:rebuild, changed_files, opts}, _from, state) do - case state.scanner do - nil -> - {:reply, {:error, :no_scanner}, state} - - scanner -> - rebuild_css(state, scanner, changed_files, opts) - end - end - defp rebuild_css(state, scanner, changed_files, opts) do changed = Enum.map(changed_files, &changed_file/1) - if Oxide.scan_files(scanner, changed) == [] do + 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) @@ -105,17 +130,73 @@ defmodule Volt.Tailwind.Worker do defp compile_rebuilt_css(state, scanner, opts) do compilation = Volt.Tailwind.Compilation.update(state.compilation, opts) - case compile_css(compilation.css, Oxide.scan(scanner), compilation.base, state) do + 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: [] @@ -135,9 +216,15 @@ defmodule Volt.Tailwind.Worker do defp changed_file(map), do: struct!(Oxide.Changed, map) - defp compile_css(css, candidates, css_base, state) do - runtime = state.runtime || Volt.Tailwind.Supervisor.runtime(state.key) - Volt.Tailwind.Runtime.call(css, candidates, css_base, runtime) + @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 diff --git a/lib/volt/watcher.ex b/lib/volt/watcher.ex index dd50a00..6e6bcd5 100644 --- a/lib/volt/watcher.ex +++ b/lib/volt/watcher.ex @@ -63,6 +63,7 @@ defmodule Volt.Watcher do base_watch_dirs: [], tailwind_dirs: [], reload_dirs: [], + explicit_ignored: [], watch_ignored: [] ] @@ -165,6 +166,8 @@ defmodule Volt.Watcher do 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 } @@ -193,7 +196,12 @@ defmodule Volt.Watcher do Enum.map(inputs.dependencies, &Path.dirname/1) ) - desired = dirs |> Enum.reject(&(&1 in state.base_watch_dirs)) |> Enum.filter(&File.dir?/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} -> @@ -225,6 +233,11 @@ defmodule Volt.Watcher 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 + @impl true def handle_call({:configuration_matches, signature}, _from, state) do {:reply, state.configuration_signature == signature, state} @@ -336,7 +349,7 @@ defmodule Volt.Watcher do {:noreply, refresh_tailwind_inputs(%{state | pending_reloads: []})} {:error, _reason} -> - {:noreply, state} + {:noreply, refresh_tailwind_inputs(%{state | tailwind_full?: true})} end end @@ -364,7 +377,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 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/priv/ts/compilers/tailwind.ts b/priv/ts/compilers/tailwind.ts index fec7e7e..a1f1570 100644 --- a/priv/ts/compilers/tailwind.ts +++ b/priv/ts/compilers/tailwind.ts @@ -76,77 +76,97 @@ 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, scan: { base: string; sources: Source[] } | null = null, - metadata = false + 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 { - 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);' + 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 { - path: themeCssPath, - 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 { - path: preflightCssPath, - 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 { + path: utilitiesCssPath, + 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 } - 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 - - for (const dependency of spec.dependencies) dependencies.add(dependency) - const mod = requireResolvedModule(spec, moduleCache) - return { path: spec.path, module: unwrapModule(mod), base: spec.base } - } - }) + 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 + } if (scan) { let automatic: Source[] = [] @@ -161,6 +181,17 @@ async function compileTailwindCss( ...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 ? { @@ -245,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/volt/dev_server/basic_test.exs b/test/volt/dev_server/basic_test.exs index 50adb08..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,8 @@ defmodule Volt.DevServer.BasicTest do assert [{pid, _}] = Registry.lookup(Volt.Dev.WatcherRegistry, key) assert Process.alive?(pid) - on_exit(fn -> - if Process.alive?(pid), - do: DynamicSupervisor.terminate_child(Volt.Dev.WatcherSupervisor, pid) - end) + 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 diff --git a/test/volt/dev_test.exs b/test/volt/dev_test.exs index 580d558..1c939cf 100644 --- a/test/volt/dev_test.exs +++ b/test/volt/dev_test.exs @@ -1,6 +1,31 @@ defmodule Volt.DevTest do use ExUnit.Case, async: false + @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 + @tag :tmp_dir test "Tailwind contexts are isolated and released with explicit sessions", %{tmp_dir: root} do a = make_ref() @@ -106,32 +131,20 @@ defmodule Volt.DevTest do root = Path.expand("volt-dev-#{System.unique_integer([:positive])}", System.tmp_dir!()) File.mkdir_p!(root) - assert :ok = Volt.Dev.ensure_watcher(id: :default, root: root, tailwind: false) - assert [{pid, _}] = Registry.lookup(Volt.Dev.WatcherRegistry, {:watcher, :default, root}) + default = Volt.Dev.session_identity(root: root) + admin = Volt.Dev.session_identity(root: root, id: :admin) on_exit(fn -> - if Process.alive?(pid) do - DynamicSupervisor.terminate_child(Volt.Dev.WatcherSupervisor, pid) - end - + Volt.Dev.stop(default) + Volt.Dev.stop(admin) File.rm_rf!(root) 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}) - - assert :ok = Volt.Dev.ensure_watcher(id: :admin, root: root, tailwind: false) - - assert [{profile_pid, _}] = - Registry.lookup(Volt.Dev.WatcherRegistry, {:watcher, :admin, root}) - - on_exit(fn -> - if Process.alive?(profile_pid) do - DynamicSupervisor.terminate_child(Volt.Dev.WatcherSupervisor, profile_pid) - end - 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/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/tailwind/runtime_test.exs b/test/volt/tailwind/runtime_test.exs index 4067fd4..b673367 100644 --- a/test/volt/tailwind/runtime_test.exs +++ b/test/volt/tailwind/runtime_test.exs @@ -33,44 +33,85 @@ defmodule Volt.Tailwind.RuntimeTest do assert metadata.code =~ ".custom" end - test "compiler death during a call returns an error and permits recovery" do - session = make_ref() - wrapper = Volt.Tailwind.Supervisor.runtime({:session, session, :css}) - on_exit(fn -> Volt.Tailwind.Supervisor.release_runtime(session) end) - parent = self() + 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 + } + ) - {:ok, compiler} = - QuickBEAM.start( - handlers: %{ - "blocked" => fn [] -> - send(parent, {:compiler_blocked, self()}) + # 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) - receive do - :release -> :ok - end - end - } - ) + {:ok, _} = + QuickBEAM.eval(compiler, "globalThis.compileTailwindCss = () => Beam.call('blocked')") - # 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) + task = + Task.async(fn -> + apply(Volt.Tailwind.Runtime, operation, ["", [], File.cwd!(), wrapper]) + end) - {:ok, _} = - QuickBEAM.eval(compiler, "globalThis.compileTailwindCss = () => Beam.call('blocked')") + assert_receive {:compiler_blocked, handler} + Process.exit(compiler, :kill) + send(handler, :release) + assert {:error, {:compiler_exit, _}} = Task.await(task) + assert Process.alive?(wrapper) - task = Task.async(fn -> Volt.Tailwind.Runtime.call("", [], 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 {:ok, css} = - Volt.Tailwind.Runtime.call("@tailwind utilities;", ["flex"], File.cwd!(), wrapper) + assert css =~ ".flex" + end + end - assert css =~ ".flex" + 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 diff --git a/test/volt/tailwind/worker_test.exs b/test/volt/tailwind/worker_test.exs index 6a63b64..7d0839e 100644 --- a/test/volt/tailwind/worker_test.exs +++ b/test/volt/tailwind/worker_test.exs @@ -3,6 +3,57 @@ defmodule Volt.Tailwind.WorkerTest do @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) diff --git a/test/volt/watcher_test.exs b/test/volt/watcher_test.exs index 9a6e273..ed8dbbf 100644 --- a/test/volt/watcher_test.exs +++ b/test/volt/watcher_test.exs @@ -12,6 +12,147 @@ 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") @@ -107,10 +248,19 @@ defmodule Volt.WatcherTest do 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 }") - recovered = %{pending | tailwind_full?: true, config: state.config} + + 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]}} From e37e0f1591ab9166bc4d15b27722c9cc6d86a420 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 10 Sep 2026 11:00:10 +0300 Subject: [PATCH 06/22] fix(dev): handle session lookup races and explicit host attachment --- .reach.exs | 1 + guides/features/hmr.md | 19 +++++++++++++ lib/volt/dev.ex | 8 +++++- lib/volt/dev/session/call.ex | 13 +++++++++ lib/volt/dev/session/supervisor.ex | 8 ++++-- lib/volt/dev_server.ex | 10 +++++++ test/volt/dev/session/call_test.exs | 15 +++++++++++ test/volt/dev_server/requests_test.exs | 37 ++++++++++++++++++++++++++ 8 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 lib/volt/dev/session/call.ex create mode 100644 test/volt/dev/session/call_test.exs diff --git a/.reach.exs b/.reach.exs index 9ac49aa..0ec8eb2 100644 --- a/.reach.exs +++ b/.reach.exs @@ -138,6 +138,7 @@ infrastructure = [ "Volt.Cache", "Volt.Dev", "Volt.Dev.State", + "Volt.Dev.Session.Call", "Volt.Dev.Session.State", "Volt.Dev.Session.Supervisor", "Volt.Dev.ConsoleForwarder", diff --git a/guides/features/hmr.md b/guides/features/hmr.md index b94cbd7..342deca 100644 --- a/guides/features/hmr.md +++ b/guides/features/hmr.md @@ -2,6 +2,25 @@ 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. + ## What Gets Updated | File type | Action | diff --git a/lib/volt/dev.ex b/lib/volt/dev.ex index 686f64b..039d931 100644 --- a/lib/volt/dev.ex +++ b/lib/volt/dev.ex @@ -39,7 +39,13 @@ defmodule Volt.Dev do key = {:session, session} opts = normalize_options(Keyword.put(opts, :session, session), root) - :global.trans({{__MODULE__, key}, self()}, fn -> start_watcher(key, opts) end, [node()]) + :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 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/supervisor.ex b/lib/volt/dev/session/supervisor.ex index 40a2d16..46818f0 100644 --- a/lib/volt/dev/session/supervisor.ex +++ b/lib/volt/dev/session/supervisor.ex @@ -9,7 +9,9 @@ defmodule Volt.Dev.Session.Supervisor do end @doc "Read the current generation from a running session subtree." - def tables(supervisor) do + 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 @@ -19,7 +21,9 @@ defmodule Volt.Dev.Session.Supervisor do end @doc "Read the session's last successful stylesheet." - def stylesheet(supervisor) do + 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 diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index 999c495..347545c 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -167,6 +167,16 @@ defmodule Volt.DevServer do 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 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_server/requests_test.exs b/test/volt/dev_server/requests_test.exs index 31ca1ac..017185e 100644 --- a/test/volt/dev_server/requests_test.exs +++ b/test/volt/dev_server/requests_test.exs @@ -1,6 +1,43 @@ 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() From fe15ac94f7256e79aa0b8bbf7d42c5ae63594e9c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 10 Sep 2026 11:15:50 +0300 Subject: [PATCH 07/22] fix(build): map shared chunk imports to emitted manifest identities --- lib/volt/builder/output.ex | 26 ++++++++++++++++++++++++++ test/volt/build_test.exs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/lib/volt/builder/output.ex b/lib/volt/builder/output.ex index 70a3c2c..5ce655e 100644 --- a/lib/volt/builder/output.ex +++ b/lib/volt/builder/output.ex @@ -226,9 +226,26 @@ defmodule Volt.Builder.Output do 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) @@ -314,6 +331,15 @@ defmodule Volt.Builder.Output do end) end + 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 if String.ends_with?(output.file_name, ".map") do acc diff --git a/test/volt/build_test.exs b/test/volt/build_test.exs index da46f0f..bb29470 100644 --- a/test/volt/build_test.exs +++ b/test/volt/build_test.exs @@ -14,6 +14,41 @@ defmodule Volt.BuildTest do {: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 From 9b25b4d00f023cf70240a89069e5812a07af2212 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 10 Sep 2026 22:32:49 +0300 Subject: [PATCH 08/22] fix(vue): assemble template-only component exports --- lib/volt/plugin/vue.ex | 28 ++++++++++++++++++++++++++-- test/volt/plugin/vue_test.exs | 27 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 test/volt/plugin/vue_test.exs diff --git a/lib/volt/plugin/vue.ex b/lib/volt/plugin/vue.ex index 2abfaca..7f8001a 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,13 +35,15 @@ 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 {:ok, %Volt.Pipeline.Result{ code: result.code - |> Vize.SFC.rewrite_asset_references(assets) + |> assemble_template_component(descriptor, scope_id, sfc_opts[:vapor]) + |> SFC.rewrite_asset_references(assets) |> append_template_asset_imports(assets) |> maybe_append_style_imports(path, source, opts), sourcemap: result.source_map, @@ -51,6 +58,23 @@ defmodule Volt.Plugin.Vue do end end + defp assemble_template_component( + code, + %{script: nil, script_setup: nil, template: template, styles: styles}, + scope_id, + false + ) + when not is_nil(template) do + component = + if Enum.any?(styles, & &1.scoped), + do: "{ render, __scopeId: #{Jason.encode!("data-v-" <> scope_id)} }", + else: "{ render }" + + code <> "\nexport default " <> component <> ";\n" + end + + defp assemble_template_component(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/test/volt/plugin/vue_test.exs b/test/volt/plugin/vue_test.exs new file mode 100644 index 0000000..e3140a9 --- /dev/null +++ b/test/volt/plugin/vue_test.exs @@ -0,0 +1,27 @@ +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 "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 From d0c31113d831ded053ce5f3c5d0df93f40584d63 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 10 Sep 2026 23:39:47 +0300 Subject: [PATCH 09/22] fix(dev): rewrite absolute asset-root imports in virtual modules --- lib/volt/dev_server.ex | 3 +++ .../volt/dev_server/import_rewriting_test.exs | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index 347545c..fa9a76d 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -742,6 +742,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/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") From 4ba0df29405cf10c8e266e1616d3d6e2563b155e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 13 Sep 2026 23:47:24 +0300 Subject: [PATCH 10/22] fix: address session request and dependency asset review findings --- .reach.exs | 1 + lib/volt/application.ex | 1 + lib/volt/builder/manifest_entry.ex | 18 +++++++++ lib/volt/config/tailwind.ex | 10 +++-- lib/volt/css/asset_url_rewriter.ex | 25 ++++++++---- lib/volt/dev.ex | 12 +++--- lib/volt/dev/assets.ex | 34 ++++++++++++++++ lib/volt/dev/session/state.ex | 28 +++++++++---- lib/volt/dev/session/supervisor.ex | 8 ++-- lib/volt/dev/session/tables.ex | 2 +- lib/volt/dev/session/watcher.ex | 9 ----- lib/volt/dev/state.ex | 1 + lib/volt/dev_server.ex | 42 +++++++++++++++++-- lib/volt/watcher.ex | 12 ------ test/volt/builder/manifest_entry_test.exs | 11 +++++ test/volt/config/tailwind_test.exs | 10 +++++ test/volt/dev/assets_test.exs | 49 +++++++++++++++++++++++ test/volt/dev_test.exs | 18 +++++++++ 18 files changed, 236 insertions(+), 55 deletions(-) create mode 100644 lib/volt/dev/assets.ex create mode 100644 test/volt/dev/assets_test.exs diff --git a/.reach.exs b/.reach.exs index 0ec8eb2..c8a6b70 100644 --- a/.reach.exs +++ b/.reach.exs @@ -138,6 +138,7 @@ infrastructure = [ "Volt.Cache", "Volt.Dev", "Volt.Dev.State", + "Volt.Dev.Assets", "Volt.Dev.Session.Call", "Volt.Dev.Session.State", "Volt.Dev.Session.Supervisor", diff --git a/lib/volt/application.ex b/lib/volt/application.ex index 6580f8b..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() diff --git a/lib/volt/builder/manifest_entry.ex b/lib/volt/builder/manifest_entry.ex index 8f5affb..bd5fb5f 100644 --- a/lib/volt/builder/manifest_entry.ex +++ b/lib/volt/builder/manifest_entry.ex @@ -45,6 +45,24 @@ defmodule Volt.Builder.ManifestEntry do } 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/config/tailwind.ex b/lib/volt/config/tailwind.ex index d4b597a..d1f2ea3 100644 --- a/lib/volt/config/tailwind.ex +++ b/lib/volt/config/tailwind.ex @@ -16,11 +16,15 @@ defmodule Volt.Config.Tailwind do dev_url: String.t() } - @spec enabled?(keyword() | nil) :: boolean() + @spec enabled?(keyword() | boolean() | nil) :: boolean() + def enabled?(true), do: true def enabled?(config), do: is_list(config) and config != [] - @spec new(keyword(), keyword()) :: t() - def new(config, overrides \\ []) do + @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) diff --git a/lib/volt/css/asset_url_rewriter.ex b/lib/volt/css/asset_url_rewriter.ex index c22ad70..0e5643c 100644 --- a/lib/volt/css/asset_url_rewriter.ex +++ b/lib/volt/css/asset_url_rewriter.ex @@ -75,12 +75,13 @@ defmodule Volt.CSS.AssetURLRewriter do @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 @@ -118,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 diff --git a/lib/volt/dev.ex b/lib/volt/dev.ex index 039d931..3cec2a3 100644 --- a/lib/volt/dev.ex +++ b/lib/volt/dev.ex @@ -89,7 +89,7 @@ defmodule Volt.Dev do case Enum.find(Supervisor.which_children(supervisor), fn {id, _, _, _} -> id == Volt.Dev.Session.Watcher end) do - {_, pid, _, _} when is_pid(pid) -> verify_configuration(pid, opts) + {_, pid, _, _} when is_pid(pid) -> verify_configuration(supervisor, pid, opts) _ -> {:error, :session_restarting} end end @@ -143,12 +143,14 @@ defmodule Volt.Dev do |> Keyword.update(:reload_dirs, [], &Enum.map(&1, fn path -> Path.expand(path) end)) end - defp verify_configuration(pid, opts) do + defp verify_configuration(supervisor, pid, opts) do signature = opts |> Keyword.drop([:name, :managed_key]) |> Map.new() - if GenServer.call(pid, {:configuration_matches, signature}), - do: {:ok, pid}, - else: {:error, :session_configuration_conflict} + 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/session/state.ex b/lib/volt/dev/session/state.ex index a7222a6..8d730fd 100644 --- a/lib/volt/dev/session/state.ex +++ b/lib/volt/dev/session/state.ex @@ -4,27 +4,39 @@ defmodule Volt.Dev.Session.State do use GenServer alias Volt.Dev.Session.Tables - def start_link(opts), do: GenServer.start_link(__MODULE__, :ok, opts) + 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(:ok) do + def init(configuration) do tables = - Map.new([:cache, :imports, :globs, :styles, :modules], fn kind -> + Map.new([:cache, :imports, :globs, :styles, :modules, :assets], fn kind -> {kind, :ets.new(kind, [:set, :public, read_concurrency: true])} end) - {:ok, struct!(Tables, Map.merge(tables, %{owner: self(), generation: make_ref()}))} + 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, tables), do: {:reply, tables, tables} + 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, tables) do - tables = %{tables | stylesheet_worker: worker} - {:reply, tables, tables} + 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 index 46818f0..0123cee 100644 --- a/lib/volt/dev/session/supervisor.ex +++ b/lib/volt/dev/session/supervisor.ex @@ -46,16 +46,14 @@ defmodule Volt.Dev.Session.Supervisor do worker_name = {:via, Registry, {Volt.Dev.WatcherRegistry, {:stylesheet, identity}}} [ - {Volt.Dev.Session.State, name: owner_name}, + {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( - :configuration_signature, - watcher_opts |> Keyword.delete(:name) |> Map.new() - ) |> Keyword.put(:tailwind_outdir, Keyword.get(watcher_opts, :tailwind_sink)) |> Keyword.put(:state_owner, owner_name) |> Keyword.put(:tailwind_worker, worker_name) diff --git a/lib/volt/dev/session/tables.ex b/lib/volt/dev/session/tables.ex index ae319e9..065abcf 100644 --- a/lib/volt/dev/session/tables.ex +++ b/lib/volt/dev/session/tables.ex @@ -1,6 +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] + @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 index e7df315..39d0205 100644 --- a/lib/volt/dev/session/watcher.ex +++ b/lib/volt/dev/session/watcher.ex @@ -6,15 +6,6 @@ defmodule Volt.Dev.Session.Watcher do worker = GenServer.whereis(Keyword.fetch!(opts, :tailwind_worker)) tables = Volt.Dev.Session.State.attach_stylesheet(state_owner, worker) - opts = - Keyword.put_new( - opts, - :configuration_signature, - opts - |> Keyword.drop([:name, :state_owner, :tailwind_worker, :tailwind_runtime]) - |> Map.new() - ) - Volt.Watcher.start_link(Keyword.put(opts, :tables, tables)) end diff --git a/lib/volt/dev/state.ex b/lib/volt/dev/state.ex index 28b1cba..ac5f6c0 100644 --- a/lib/volt/dev/state.ex +++ b/lib/volt/dev/state.ex @@ -2,6 +2,7 @@ defmodule Volt.Dev.State do @moduledoc "Cleanup of session-scoped compilation and dependency state." def clear(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) diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index fa9a76d..b4daf8f 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -192,7 +192,14 @@ defmodule Volt.DevServer do error in ArgumentError -> case __STACKTRACE__ do [{:ets, _operation, [table | _], _} | _] - when table in [tables.cache, tables.imports, tables.globs, tables.styles, tables.modules] -> + 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") @@ -217,7 +224,8 @@ defmodule Volt.DevServer do css, config.stylesheet_source, config.root, - config.prefix + config.prefix, + grant_asset: &Volt.Dev.Assets.register(&1, config.tables) ) end @@ -234,6 +242,28 @@ defmodule Volt.DevServer do 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, [session: config.session], @@ -670,14 +700,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 diff --git a/lib/volt/watcher.ex b/lib/volt/watcher.ex index 6e6bcd5..d92f4f8 100644 --- a/lib/volt/watcher.ex +++ b/lib/volt/watcher.ex @@ -47,7 +47,6 @@ defmodule Volt.Watcher do defstruct [ :root, :config, - :configuration_signature, :owner_monitor, :managed_key, :tables, @@ -153,12 +152,6 @@ defmodule Volt.Watcher do owner_monitor: owner_monitor, tables: Keyword.get(opts, :tables), managed_key: Keyword.get(opts, :managed_key), - configuration_signature: - Keyword.get( - opts, - :configuration_signature, - opts |> Keyword.drop([:name, :managed_key]) |> Map.new() - ), session: Keyword.get(opts, :session, :default), fs_pids: fs_pids, config: config, @@ -238,11 +231,6 @@ defmodule Volt.Watcher do if File.dir?(path) or parent == path, do: path, else: existing_watch_parent(parent) end - @impl true - def handle_call({:configuration_matches, signature}, _from, state) do - {:reply, state.configuration_signature == signature, state} - 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 diff --git a/test/volt/builder/manifest_entry_test.exs b/test/volt/builder/manifest_entry_test.exs index bb9661d..f7cfcd2 100644 --- a/test/volt/builder/manifest_entry_test.exs +++ b/test/volt/builder/manifest_entry_test.exs @@ -3,6 +3,17 @@ defmodule Volt.Builder.ManifestEntryTest do 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", diff --git a/test/volt/config/tailwind_test.exs b/test/volt/config/tailwind_test.exs index 2bc959e..258c1fc 100644 --- a/test/volt/config/tailwind_test.exs +++ b/test/volt/config/tailwind_test.exs @@ -1,6 +1,16 @@ 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( 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_test.exs b/test/volt/dev_test.exs index 1c939cf..8966c5d 100644 --- a/test/volt/dev_test.exs +++ b/test/volt/dev_test.exs @@ -1,6 +1,24 @@ defmodule Volt.DevTest do use ExUnit.Case, async: false + @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") From 93d485e57df19ee2041ebf47bab72e339cd8df92 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 00:02:28 +0300 Subject: [PATCH 11/22] fix(vue): attach scoped CSS identity to script-bearing components --- guides/features/hmr.md | 8 +++++++ lib/volt/plugin/vue.ex | 43 +++++++++++++++++++++++++---------- test/volt/plugin/vue_test.exs | 18 +++++++++++++++ 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/guides/features/hmr.md b/guides/features/hmr.md index 342deca..fe5bc66 100644 --- a/guides/features/hmr.md +++ b/guides/features/hmr.md @@ -21,6 +21,14 @@ 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/lib/volt/plugin/vue.ex b/lib/volt/plugin/vue.ex index 7f8001a..93384a0 100644 --- a/lib/volt/plugin/vue.ex +++ b/lib/volt/plugin/vue.ex @@ -38,15 +38,19 @@ defmodule Volt.Plugin.Vue do 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 - |> assemble_template_component(descriptor, scope_id, sfc_opts[:vapor]) + 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, @@ -60,20 +64,35 @@ defmodule Volt.Plugin.Vue do defp assemble_template_component( code, - %{script: nil, script_setup: nil, template: template, styles: styles}, - scope_id, + %{script: nil, script_setup: nil, template: template}, false ) - when not is_nil(template) do - component = - if Enum.any?(styles, & &1.scoped), - do: "{ render, __scopeId: #{Jason.encode!("data-v-" <> scope_id)} }", - else: "{ render }" + 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 = + "Object.assign(#{expression}, { __scopeId: #{Jason.encode!("data-v-" <> scope_id)} })" - code <> "\nexport default " <> component <> ";\n" + Volt.JS.Patch.new(first, last, value) + end + + Volt.JS.Patch.apply(code, patches) + else + code + end end - defp assemble_template_component(code, _descriptor, _scope_id, _vapor), do: code + defp attach_scope(code, _descriptor, _scope_id, _vapor), do: code @impl true def extract_imports(path, source, _opts) do diff --git a/test/volt/plugin/vue_test.exs b/test/volt/plugin/vue_test.exs index e3140a9..bc23fc2 100644 --- a/test/volt/plugin/vue_test.exs +++ b/test/volt/plugin/vue_test.exs @@ -12,6 +12,24 @@ defmodule Volt.Plugin.VueTest do 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 + test "template-only scoped CSS and component use the same scope identity" do file = "/fixtures/Scoped.vue" From af5ef72b951cc3051461517c896a31b66e47be76 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 09:47:13 +0300 Subject: [PATCH 12/22] Fix scoped Vue assembly with shadowed module bindings --- CHANGELOG.md | 1 + lib/volt/plugin/vue.ex | 2 +- test/volt/plugin/vue_test.exs | 38 ++++++++++++++++++++++++++++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0258a90..9d2931f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ ### Fixed - 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`. ## 0.17.11 - 2026-09-04 diff --git a/lib/volt/plugin/vue.ex b/lib/volt/plugin/vue.ex index 93384a0..999bc10 100644 --- a/lib/volt/plugin/vue.ex +++ b/lib/volt/plugin/vue.ex @@ -81,7 +81,7 @@ defmodule Volt.Plugin.Vue do expression = binary_part(code, first, last - first) value = - "Object.assign(#{expression}, { __scopeId: #{Jason.encode!("data-v-" <> scope_id)} })" + "((component) => { component.__scopeId = #{Jason.encode!("data-v-" <> scope_id)}; return component; })(#{expression})" Volt.JS.Patch.new(first, last, value) end diff --git a/test/volt/plugin/vue_test.exs b/test/volt/plugin/vue_test.exs index bc23fc2..1577d9c 100644 --- a/test/volt/plugin/vue_test.exs +++ b/test/volt/plugin/vue_test.exs @@ -23,13 +23,49 @@ defmodule Volt.Plugin.VueTest do 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.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" From dab017eb537f800fd73b8bc801ce25d8e491348a Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 10:56:57 +0300 Subject: [PATCH 13/22] Require QuickBEAM 0.11.1 and refresh runtime dependencies --- CHANGELOG.md | 1 + mix.exs | 2 +- mix.lock | 10 +++++----- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d2931f..34bf3b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### 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. diff --git a/mix.exs b/mix.exs index 0029516..ae6c8fd 100644 --- a/mix.exs +++ b/mix.exs @@ -38,7 +38,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"}, diff --git a/mix.lock b/mix.lock index affe00f..276008a 100644 --- a/mix.lock +++ b/mix.lock @@ -20,7 +20,7 @@ "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"}, "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"}, @@ -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,9 +49,9 @@ "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"}, @@ -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"}, } From e3b5b268cfff32af59719767ba40b7387430889c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 11:15:20 +0300 Subject: [PATCH 14/22] Align source paths with established Volt module names --- lib/volt/builder/{context/build.ex => build_context.ex} | 0 lib/volt/builder/{collector => }/dependencies.ex | 0 lib/volt/builder/{context/output.ex => output_context.ex} | 0 lib/volt/builder/{output/file.ex => output_file.ex} | 0 lib/volt/js/{imports/extractor.ex => import_extractor.ex} | 0 lib/volt/js/{imports => import_extractor}/result.ex | 0 lib/volt/js/{prebundle/entry.ex => prebundle_entry.ex} | 0 lib/volt/js/{prebundle => prebundle_entry}/export.ex | 0 lib/volt/js/{prebundle => prebundle_entry}/import.ex | 0 9 files changed, 0 insertions(+), 0 deletions(-) rename lib/volt/builder/{context/build.ex => build_context.ex} (100%) rename lib/volt/builder/{collector => }/dependencies.ex (100%) rename lib/volt/builder/{context/output.ex => output_context.ex} (100%) rename lib/volt/builder/{output/file.ex => output_file.ex} (100%) rename lib/volt/js/{imports/extractor.ex => import_extractor.ex} (100%) rename lib/volt/js/{imports => import_extractor}/result.ex (100%) rename lib/volt/js/{prebundle/entry.ex => prebundle_entry.ex} (100%) rename lib/volt/js/{prebundle => prebundle_entry}/export.ex (100%) rename lib/volt/js/{prebundle => prebundle_entry}/import.ex (100%) 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/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/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 100% rename from lib/volt/builder/output/file.ex rename to lib/volt/builder/output_file.ex 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 From 6097ef58d46751c150af2cb2a47e0004357dfdcf Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 11:19:16 +0300 Subject: [PATCH 15/22] Normalize Mix-task test namespaces and test support compilation --- mix.exs | 5 ++++- test/mix/tasks/volt/install_test.exs | 2 +- test/mix/tasks/volt/js/check_test.exs | 2 +- test/mix/tasks/volt/lint_test.exs | 2 +- test/support/volt/{builder_case.exs => builder_case.ex} | 0 .../support/volt/{dev_server_case.exs => dev_server_case.ex} | 0 test/test_helper.exs | 4 ---- 7 files changed, 7 insertions(+), 8 deletions(-) rename test/support/volt/{builder_case.exs => builder_case.ex} (100%) rename test/support/volt/{dev_server_case.exs => dev_server_case.ex} (100%) diff --git a/mix.exs b/mix.exs index ae6c8fd..2f43913 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}, diff --git a/test/mix/tasks/volt/install_test.exs b/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 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 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"]) From 7b320e33aafd002198aa3ea5c87e8f98f590d161 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 11:34:57 +0300 Subject: [PATCH 16/22] Refresh Phoenix example lockfile for current Volt dependencies --- examples/vanilla/mix.lock | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/examples/vanilla/mix.lock b/examples/vanilla/mix.lock index f85d5b5..2f5ad70 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"}, + "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"}, + "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.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"}, "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_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"}, } From cdc4ecf618b99f75fa155c1a111ad8793156a464 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 11:37:50 +0300 Subject: [PATCH 17/22] Clarify discovery and cleanup APIs and move helpers to their owners --- .reach.exs | 4 ++-- AGENTS.md | 4 ++++ CHANGELOG.md | 4 ++++ lib/mix/tasks/volt/js/check.ex | 4 ++-- lib/mix/tasks/volt/js/format.ex | 2 +- lib/mix/tasks/volt/lint.ex | 2 +- lib/volt/builder.ex | 2 +- lib/volt/builder/artifact.ex | 2 +- lib/volt/builder/naming.ex | 5 +++++ lib/volt/builder/writer.ex | 2 +- lib/volt/dev.ex | 2 +- lib/volt/dev/{state.ex => cleanup.ex} | 4 ++-- lib/volt/dev_server.ex | 9 ++++++++- lib/volt/format.ex | 11 ----------- lib/volt/js/{helpers.ex => discovery.ex} | 8 ++++---- lib/volt/test/discovery.ex | 2 +- lib/volt/watcher.ex | 2 +- mix.exs | 2 +- test/volt/builder/artifact_test.exs | 2 +- test/volt/builder/naming_test.exs | 6 ++++++ test/volt/js/{helpers_test.exs => discovery_test.exs} | 10 +++++----- 21 files changed, 52 insertions(+), 37 deletions(-) rename lib/volt/dev/{state.ex => cleanup.ex} (88%) rename lib/volt/js/{helpers.ex => discovery.ex} (86%) rename test/volt/js/{helpers_test.exs => discovery_test.exs} (78%) diff --git a/.reach.exs b/.reach.exs index c8a6b70..0c8fdc1 100644 --- a/.reach.exs +++ b/.reach.exs @@ -101,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", @@ -137,7 +137,7 @@ infrastructure = [ "Volt.Builder.Publication", "Volt.Cache", "Volt.Dev", - "Volt.Dev.State", + "Volt.Dev.Cleanup", "Volt.Dev.Assets", "Volt.Dev.Session.Call", "Volt.Dev.Session.State", 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 34bf3b0..20019fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 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. diff --git a/lib/mix/tasks/volt/js/check.ex b/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 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 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/builder.ex b/lib/volt/builder.ex index abc8c9a..65e3ffc 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -563,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 diff --git a/lib/volt/builder/artifact.ex b/lib/volt/builder/artifact.ex index 8d2c7b2..5d61222 100644 --- a/lib/volt/builder/artifact.ex +++ b/lib/volt/builder/artifact.ex @@ -10,7 +10,7 @@ defmodule Volt.Builder.Artifact do def asset(source_path, content) do ext = Path.extname(source_path) name = Path.basename(source_path, ext) - hash = Volt.Format.content_hash(content) + hash = Volt.Builder.Naming.hash(content) %__MODULE__{file: "#{name}-#{hash}#{ext}", content: content} 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/writer.ex b/lib/volt/builder/writer.ex index b4ba3a9..e58ec5d 100644 --- a/lib/volt/builder/writer.ex +++ b/lib/volt/builder/writer.ex @@ -127,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}" diff --git a/lib/volt/dev.ex b/lib/volt/dev.ex index 3cec2a3..d858639 100644 --- a/lib/volt/dev.ex +++ b/lib/volt/dev.ex @@ -131,7 +131,7 @@ defmodule Volt.Dev do end end - Volt.Dev.State.clear(session) + Volt.Dev.Cleanup.run(session) end defp normalize_options(opts, root) do diff --git a/lib/volt/dev/state.ex b/lib/volt/dev/cleanup.ex similarity index 88% rename from lib/volt/dev/state.ex rename to lib/volt/dev/cleanup.ex index ac5f6c0..1613654 100644 --- a/lib/volt/dev/state.ex +++ b/lib/volt/dev/cleanup.ex @@ -1,7 +1,7 @@ -defmodule Volt.Dev.State do +defmodule Volt.Dev.Cleanup do @moduledoc "Cleanup of session-scoped compilation and dependency state." - def clear(session) do + def run(session) do Volt.Dev.Assets.clear_session(session) Volt.Tailwind.Supervisor.release_runtime(session) Volt.Cache.clear_session(session) diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index b4daf8f..a9326fe 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -428,10 +428,17 @@ 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?) 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/js/helpers.ex b/lib/volt/js/discovery.ex similarity index 86% rename from lib/volt/js/helpers.ex rename to lib/volt/js/discovery.ex index a092c94..8e9e8fa 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,8 +33,8 @@ 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: [] 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 d92f4f8..31646a5 100644 --- a/lib/volt/watcher.ex +++ b/lib/volt/watcher.ex @@ -725,7 +725,7 @@ defmodule Volt.Watcher do 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.State.clear(state.session) + 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/mix.exs b/mix.exs index 2f43913..0c9a529 100644 --- a/mix.exs +++ b/mix.exs @@ -221,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/test/volt/builder/artifact_test.exs b/test/volt/builder/artifact_test.exs index d44aefc..d02e483 100644 --- a/test/volt/builder/artifact_test.exs +++ b/test/volt/builder/artifact_test.exs @@ -5,7 +5,7 @@ defmodule Volt.Builder.ArtifactTest do test "asset identity is derived from content and preserves the extension" do content = <<0, 255, 42>> - hash = Volt.Format.content_hash(content) + hash = Volt.Builder.Naming.hash(content) assert Artifact.asset("/source/fonts/site.woff2", content) == %Artifact{file: "site-#{hash}.woff2", content: content} 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/js/helpers_test.exs b/test/volt/js/discovery_test.exs similarity index 78% rename from test/volt/js/helpers_test.exs rename to test/volt/js/discovery_test.exs index 8eea207..f2017f0 100644 --- a/test/volt/js/helpers_test.exs +++ b/test/volt/js/discovery_test.exs @@ -1,12 +1,12 @@ -defmodule Volt.JS.HelpersTest do +defmodule Volt.JS.DiscoveryTest do use ExUnit.Case, async: false - alias Volt.JS.Helpers + alias Volt.JS.Discovery setup do tmp_dir = Path.expand( - "volt-js-helpers-#{System.unique_integer([:positive])}", + "volt-js-discovery-#{System.unique_integer([:positive])}", System.tmp_dir!() ) @@ -40,8 +40,8 @@ defmodule Volt.JS.HelpersTest do 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")] + 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, nil), do: Application.delete_env(:volt, key) From 41c382af5851bffe71b3992b5d2062894f5c27ae Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 19:25:40 +0300 Subject: [PATCH 18/22] Require patched Bandit and Igniter dependencies --- CHANGELOG.md | 5 +++++ examples/react/mix.exs | 2 +- examples/react/mix.lock | 39 +++++++++++++++++++++------------------ examples/solid/mix.exs | 2 +- examples/solid/mix.lock | 39 +++++++++++++++++++++------------------ examples/svelte/mix.exs | 2 +- examples/svelte/mix.lock | 39 +++++++++++++++++++++------------------ examples/vanilla/mix.exs | 2 +- examples/vanilla/mix.lock | 2 +- examples/vue/mix.exs | 2 +- examples/vue/mix.lock | 39 +++++++++++++++++++++------------------ mix.exs | 4 ++-- mix.lock | 8 ++++---- 13 files changed, 101 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20019fd..447d506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ - 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 bundled Phoenix examples to address HTTP/2 header validation and connection-window starvation (CVE-2026-75484, CVE-2026-74836). + ## 0.17.11 - 2026-09-04 ### Added 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..0359dfc 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"}, + "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.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"}, "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_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"}, + "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..0359dfc 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"}, + "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.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"}, "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_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"}, + "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..0359dfc 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"}, + "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.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"}, "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_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"}, + "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 2f5ad70..43b1bca 100644 --- a/examples/vanilla/mix.lock +++ b/examples/vanilla/mix.lock @@ -1,5 +1,5 @@ %{ - "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"}, + "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"}, 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..0359dfc 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"}, + "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.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"}, "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_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"}, + "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/mix.exs b/mix.exs index 0c9a529..16f5d12 100644 --- a/mix.exs +++ b/mix.exs @@ -50,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}, @@ -58,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 diff --git a/mix.lock b/mix.lock index 276008a..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"}, @@ -22,7 +22,7 @@ "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"}, - "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"}, @@ -56,7 +56,7 @@ "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"}, From 3cbc15da1cfa6270bc6dad7bbd5c41e86b16bfc3 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 21:03:08 +0300 Subject: [PATCH 19/22] Refresh example Phoenix and LiveView security patches --- CHANGELOG.md | 4 +++- examples/react/mix.lock | 8 ++++---- examples/solid/mix.lock | 8 ++++---- examples/svelte/mix.lock | 8 ++++---- examples/vanilla/mix.lock | 6 +++--- examples/vue/mix.lock | 8 ++++---- 6 files changed, 22 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 447d506..08faa5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,9 @@ ### 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 bundled Phoenix examples to address HTTP/2 header validation and connection-window starvation (CVE-2026-75484, CVE-2026-74836). +- 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 diff --git a/examples/react/mix.lock b/examples/react/mix.lock index 0359dfc..1e04617 100644 --- a/examples/react/mix.lock +++ b/examples/react/mix.lock @@ -22,11 +22,11 @@ "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, "oxc": {:hex, :oxc, "0.17.8", "e4c50ee0c0ce09107016b5153132b3d98421665355189b531c78ae4603047853", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "590fddfadbf843d6ab94e0f04a0e769a38358a23140728095da476c8c81f4df1"}, "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.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"}, + "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.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"}, @@ -40,6 +40,6 @@ "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"}, + "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.lock b/examples/solid/mix.lock index 0359dfc..1e04617 100644 --- a/examples/solid/mix.lock +++ b/examples/solid/mix.lock @@ -22,11 +22,11 @@ "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, "oxc": {:hex, :oxc, "0.17.8", "e4c50ee0c0ce09107016b5153132b3d98421665355189b531c78ae4603047853", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "590fddfadbf843d6ab94e0f04a0e769a38358a23140728095da476c8c81f4df1"}, "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.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"}, + "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.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"}, @@ -40,6 +40,6 @@ "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"}, + "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.lock b/examples/svelte/mix.lock index 0359dfc..1e04617 100644 --- a/examples/svelte/mix.lock +++ b/examples/svelte/mix.lock @@ -22,11 +22,11 @@ "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, "oxc": {:hex, :oxc, "0.17.8", "e4c50ee0c0ce09107016b5153132b3d98421665355189b531c78ae4603047853", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "590fddfadbf843d6ab94e0f04a0e769a38358a23140728095da476c8c81f4df1"}, "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.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"}, + "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.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"}, @@ -40,6 +40,6 @@ "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"}, + "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.lock b/examples/vanilla/mix.lock index 43b1bca..9c4872f 100644 --- a/examples/vanilla/mix.lock +++ b/examples/vanilla/mix.lock @@ -22,11 +22,11 @@ "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, "oxc": {:hex, :oxc, "0.17.8", "e4c50ee0c0ce09107016b5153132b3d98421665355189b531c78ae4603047853", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "590fddfadbf843d6ab94e0f04a0e769a38358a23140728095da476c8c81f4df1"}, "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.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"}, + "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.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"}, diff --git a/examples/vue/mix.lock b/examples/vue/mix.lock index 0359dfc..1e04617 100644 --- a/examples/vue/mix.lock +++ b/examples/vue/mix.lock @@ -22,11 +22,11 @@ "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, "oxc": {:hex, :oxc, "0.17.8", "e4c50ee0c0ce09107016b5153132b3d98421665355189b531c78ae4603047853", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "590fddfadbf843d6ab94e0f04a0e769a38358a23140728095da476c8c81f4df1"}, "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.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"}, + "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.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"}, @@ -40,6 +40,6 @@ "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"}, + "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"}, } From f9bb77d4fd409939045d2666f73f65a744f77b22 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 14 Sep 2026 21:03:09 +0300 Subject: [PATCH 20/22] Preserve watcher session field access after cleanup rename --- lib/volt/watcher.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/volt/watcher.ex b/lib/volt/watcher.ex index 31646a5..7fdae3a 100644 --- a/lib/volt/watcher.ex +++ b/lib/volt/watcher.ex @@ -725,7 +725,7 @@ defmodule Volt.Watcher do 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.session != :default, do: Volt.Dev.Cleanup.run(state.session) if state.managed_key, do: Registry.unregister(Volt.Dev.WatcherRegistry, state.managed_key) :ok end From 5cf6fd26827dbeb013bd77ffb0cf9e1f495a8dfd Mon Sep 17 00:00:00 2001 From: Roman Kurakin Date: Mon, 14 Sep 2026 21:58:48 +0300 Subject: [PATCH 21/22] Fix bundle-format collisions in JavaScript tooling (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapt Roman Kurakin’s fix from a8a82b72491a34353e3700e15f873cb0414f5bd9 to the Discovery API. Make fallback discovery deterministic and restore all fixture configuration. Addresses #34. --- CHANGELOG.md | 1 + lib/volt/js/discovery.ex | 8 +++++++- lib/volt/js/format.ex | 11 ++++++++--- test/volt/js/discovery_test.exs | 29 +++++++++++++++++++++++------ test/volt/js/format_test.exs | 6 ++++++ 5 files changed, 45 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08faa5b..fd1d68a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ ### 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`. diff --git a/lib/volt/js/discovery.ex b/lib/volt/js/discovery.ex index 8e9e8fa..9a4305d 100644 --- a/lib/volt/js/discovery.ex +++ b/lib/volt/js/discovery.ex @@ -38,5 +38,11 @@ defmodule Volt.JS.Discovery do 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/test/volt/js/discovery_test.exs b/test/volt/js/discovery_test.exs index f2017f0..3f0c0ba 100644 --- a/test/volt/js/discovery_test.exs +++ b/test/volt/js/discovery_test.exs @@ -10,8 +10,10 @@ defmodule Volt.JS.DiscoveryTest do System.tmp_dir!() ) - original_format = Application.get_env(:volt, :format) - original_lint = Application.get_env(:volt, :lint) + 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")) @@ -19,8 +21,7 @@ defmodule Volt.JS.DiscoveryTest do 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) + Enum.each(original_env, fn {key, value} -> restore_env(key, value) end) File.rm_rf!(tmp_dir) end) @@ -44,6 +45,22 @@ defmodule Volt.JS.DiscoveryTest do assert Discovery.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) + 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 From e6ed03e51d5646728429a21fcc4578b95d5e4779 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 15 Sep 2026 01:04:58 +0300 Subject: [PATCH 22/22] Assert the generated page URL in browser runner integration test --- test/volt/test/browser_runner_test.exs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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') }) """)