From 059e205360c58066f6e5c6d33bf4dd1e58ee66ae Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 14 Jul 2026 16:09:51 +0200 Subject: [PATCH 001/128] Support statement splices in private templates --- CHANGELOG.md | 8 ++++++++ guides/features/plugins.md | 30 ++++++++++++++++++++++++---- lib/volt/priv.ex | 40 ++++++++++++++++++++++++++------------ lib/volt/test/plugin.ex | 11 ++++++++--- priv/ts/test/entry.ts | 1 + test/volt/priv_test.exs | 11 +++++++++++ 6 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 priv/ts/test/entry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b7f928a..c34dea2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Added + +- `Volt.Priv.js!/4` accepts AST-backed statement, property, and array-element splices for private JavaScript and TypeScript templates. + +### Changed + +- Generate configured JavaScript test imports from a private TypeScript template instead of concatenating virtual-entry source. + ## 0.17.1 - 2026-07-13 ### Fixed diff --git a/guides/features/plugins.md b/guides/features/plugins.md index 3e9659e..7b5f686 100644 --- a/guides/features/plugins.md +++ b/guides/features/plugins.md @@ -276,10 +276,32 @@ js = ``` `Volt.Priv.js!/3` reads from the OTP application's `priv` directory, binds -`$placeholder` JavaScript literals with OXC, and emits browser JavaScript. Use `OXC.splice/3` -directly when a placeholder represents multiple syntax nodes, such as object -entries or function arguments. Volt uses this pattern internally for -`import.meta.glob()` output generation. +`$placeholder` JavaScript literals with OXC, and emits browser JavaScript. + +Use the `:splices` option when a placeholder represents statements, object properties, +or array elements rather than a literal: + +```javascript +// priv/templates/plugin-entry.ts +$imports + +export function install() { + $setup +} +``` + +```elixir +Volt.Priv.js!(@templates, "plugin-entry.ts", [], + splices: [ + imports: [~s|import "./runtime.js";|, ~s|import "./styles.css";|], + setup: "registerPlugin(); startRuntime();" + ] +) +``` + +Splices use `OXC.splice/3`, so replacements are parsed as JavaScript syntax rather +than inserted with textual replacement. A replacement can be an AST node, source or +iodata containing multiple nodes, or a list of those values. ### Example: AST transform with OXC diff --git a/lib/volt/priv.ex b/lib/volt/priv.ex index a2c8097..b0f77d0 100644 --- a/lib/volt/priv.ex +++ b/lib/volt/priv.ex @@ -19,8 +19,9 @@ defmodule Volt.Priv do Volt.Priv.js!(assets, "entry.ts", id: "counter", props: %{count: 1}) `js!/3` binds `$placeholder` JavaScript literals with OXC before emitting browser - JavaScript. That keeps templates valid TypeScript or JavaScript files instead of - requiring EEx for ordinary data injection. + JavaScript. `js!/4` also supports statement, property, and array-element splices. + That keeps templates valid TypeScript or JavaScript files instead of requiring EEx + or textual source replacement. """ @type source :: atom() | {atom(), String.t()} @@ -56,6 +57,8 @@ defmodule Volt.Priv do Options: + * `:splices` - a keyword list of `$placeholder` names to statement, + property, or array-element replacements accepted by `OXC.splice/3`. * `:rewrite_specifiers` - a map or keyword list of import specifiers to rewrite after code generation. """ @@ -64,11 +67,24 @@ defmodule Volt.Priv do @spec js!(source(), String.t(), bindings(), keyword()) :: String.t() def js!(source, relative, bindings \\ [], opts \\ []) when is_binary(relative) do source - |> js_source(relative, bindings) + |> js_source(relative, bindings, Keyword.get(opts, :splices, [])) |> rewrite_specifiers(opts[:rewrite_specifiers], relative) end - defp js_source(source, relative, bindings) when bindings == [] or bindings == %{} do + defp js_source(source, relative, bindings, splices) do + if empty?(bindings) and empty?(splices) do + cached_js!(source, relative) + else + source + |> template_ast!(relative) + |> OXC.bind(literal_bindings(bindings)) + |> splice_all(splices) + |> OXC.codegen!() + |> compile(relative) + end + end + + defp cached_js!(source, relative) do key = {__MODULE__, :js, source, relative} case :persistent_term.get(key, nil) do @@ -82,14 +98,6 @@ defmodule Volt.Priv do end end - defp js_source(source, relative, bindings) do - source - |> template_ast!(relative) - |> OXC.bind(literal_bindings(bindings)) - |> OXC.codegen!() - |> compile(relative) - end - defp template_ast!(source, relative) do key = {__MODULE__, :template_ast, source, relative} @@ -108,6 +116,14 @@ defmodule Volt.Priv do Enum.map(bindings, fn {key, value} -> {key, {:literal, value}} end) end + defp splice_all(ast, splices) do + Enum.reduce(splices, ast, fn {name, replacement}, acc -> + OXC.splice(acc, name, replacement) + end) + end + + defp empty?(value), do: value == [] or value == %{} + defp rewrite_specifiers(code, nil, _filename), do: code defp rewrite_specifiers(code, [], _filename), do: code diff --git a/lib/volt/test/plugin.ex b/lib/volt/test/plugin.ex index 455e47b..e6f2a31 100644 --- a/lib/volt/test/plugin.ex +++ b/lib/volt/test/plugin.ex @@ -46,10 +46,15 @@ defmodule Volt.Test.Plugin do def load(@test_entry, opts) do imports = - Stream.concat(Keyword.fetch!(opts, :setup_files), [Keyword.fetch!(opts, :test_file)]) + opts + |> Keyword.fetch!(:setup_files) + |> Stream.concat([Keyword.fetch!(opts, :test_file)]) + |> Enum.map(&"import #{inspect(&1)};") - source = Enum.map_join(imports, "\n", &"import #{inspect(&1)};") - {:ok, source <> "\n"} + source = + Volt.Priv.js!({:volt, "ts"}, "test/entry.ts", [], splices: [imports: imports]) + + {:ok, source} end def load(specifier, _opts), do: load(specifier) diff --git a/priv/ts/test/entry.ts b/priv/ts/test/entry.ts new file mode 100644 index 0000000..f6b21b1 --- /dev/null +++ b/priv/ts/test/entry.ts @@ -0,0 +1 @@ +$imports diff --git a/test/volt/priv_test.exs b/test/volt/priv_test.exs index e49a4e6..cf95d54 100644 --- a/test/volt/priv_test.exs +++ b/test/volt/priv_test.exs @@ -37,6 +37,17 @@ defmodule Volt.PrivTest do refute code =~ "$mod_url" end + test "splices multiple statements into support modules" do + code = + Volt.Priv.js!(@support_modules, "test/entry.ts", [], + splices: [imports: ~s|import "./setup.ts"; import "./test.ts";|] + ) + + assert code =~ ~s(import "./setup.ts") + assert code =~ ~s(import "./test.ts") + refute code =~ "$imports" + end + test "rejects paths outside priv" do assert_raise ArgumentError, fn -> Volt.Priv.path(@support_modules, "../secret.ts") end assert_raise ArgumentError, fn -> Volt.Priv.path(@support_modules, "/tmp/secret.ts") end From 3e0b5b202a8a90aaed1f3b37cb1a646bbc56941c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 15 Jul 2026 17:28:20 +0200 Subject: [PATCH 002/128] Require OXC 0.17.3 --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index befa262..c279524 100644 --- a/mix.exs +++ b/mix.exs @@ -35,7 +35,7 @@ defmodule Volt.MixProject do [ {:reach, "~> 2.6.1", only: [:dev, :test], runtime: false}, {:glob_ex, "~> 0.1"}, - {:oxc, "~> 0.17.2"}, + {:oxc, "~> 0.17.3"}, {:vize, "~> 0.14.0"}, {:oxide_ex, "~> 0.2.1"}, {:quickbeam, "~> 0.10.20"}, diff --git a/mix.lock b/mix.lock index 981dda2..ac1f834 100644 --- a/mix.lock +++ b/mix.lock @@ -40,7 +40,7 @@ "npm": {:hex, :npm, "0.7.5", "d8680384b4116a29270cd1fb706da017d1be3870d7d2993de382140d7b98d378", [: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", "174f6b230669f08240a87bef76f3d9ed03e03c126fea6ea4d1a3011a1681a95f"}, "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, - "oxc": {:hex, :oxc, "0.17.2", "c668faab70561710ff9c183636ceb9856e450aa70c531d55c10b24fbca0f9eac", [: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", "e73e315562f2d495c012dbf64067ef784db4b5bb8bb20dec31b2ab71fd40bd8c"}, + "oxc": {:hex, :oxc, "0.17.3", "2274c7bc6184bb65d1967a23f8c6ee5606dc99031882fe9e598f4184c92c2d01", [: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", "9fbe12f853bf6a517acf74c6f90aae18b71f9cf288b5c3bc267df386960c3496"}, "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"}, "pegasus": {:hex, :pegasus, "0.2.6", "b4af6522326fbb2ffd1bb706e78ec05854fbcb4b03a7fe08f8db2e9de1d0be67", [:mix], [{:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0ac159f0ccab7967cf90208327cc8a35788874814c8d78e19d47104d3fc049b9"}, "pi_bridge": {:hex, :pi_bridge, "0.6.21", "f1a63da5f49fef4f6b35e98edd5400cbd64a49fb8bda84be41b7e91f3deb0b89", [:mix], [{:bandit, "~> 1.8", [hex: :bandit, repo: "hexpm", optional: false]}, {:dune, "~> 0.3", [hex: :dune, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_ast, "~> 0.12", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.1.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:quackdb, "~> 0.5.4", [hex: :quackdb, repo: "hexpm", optional: false]}, {:reach, "~> 2.6", [hex: :reach, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:req_llm, "~> 1.6", [hex: :req_llm, repo: "hexpm", optional: true]}], "hexpm", "b226fb60965a8e928186af3c3b96fe8f0fefd8c6cc7be679d6e169ba84105de9"}, From a6f55d11bd8002bf917459dd49e271e5d96cd6f7 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 15 Jul 2026 17:30:35 +0200 Subject: [PATCH 003/128] Prepare 0.17.2 release --- CHANGELOG.md | 2 ++ mix.exs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c34dea2..ee826b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.17.2 - 2026-07-13 + ### Added - `Volt.Priv.js!/4` accepts AST-backed statement, property, and array-element splices for private JavaScript and TypeScript templates. diff --git a/mix.exs b/mix.exs index c279524..db229b3 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.17.1" + @version "0.17.2" @source_url "https://github.com/elixir-volt/volt" def project do From 1afee6717c645c10483461cbc96cf2fcf417a3b5 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 15 Jul 2026 22:23:13 +0200 Subject: [PATCH 004/128] Add scoped package and private template APIs --- CHANGELOG.md | 10 ++++ guides/features/plugins.md | 26 +++++++++- lib/volt/builder.ex | 12 +++++ lib/volt/builder/context.ex | 1 + lib/volt/builder/resolver.ex | 31 ++++++++++-- lib/volt/npm.ex | 18 +++++++ lib/volt/priv.ex | 73 +++++++++++++++++++-------- lib/volt/test/plugin.ex | 2 +- test/volt/builder/resolution_test.exs | 42 +++++++++++++++ test/volt/priv_test.exs | 19 +++++++ 10 files changed, 205 insertions(+), 29 deletions(-) create mode 100644 lib/volt/npm.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index ee826b1..bc525a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +### Added + +- `Volt.Builder` accepts importer-scoped package directories through `:package_scopes`. +- `Volt.NPM.install!/2` exposes isolated, npm_ex-backed package acquisition for frameworks and plugins. +- `Volt.Priv.render!/4` applies literal bindings and AST splices while preserving TypeScript for a subsequent Volt build. + +### Fixed + +- Invalidate cached `Volt.Priv` templates and compiled modules when their source changes. + ## 0.17.2 - 2026-07-13 ### Added diff --git a/guides/features/plugins.md b/guides/features/plugins.md index 7b5f686..5b8a1c0 100644 --- a/guides/features/plugins.md +++ b/guides/features/plugins.md @@ -291,7 +291,7 @@ export function install() { ``` ```elixir -Volt.Priv.js!(@templates, "plugin-entry.ts", [], +Volt.Priv.render!(@templates, "plugin-entry.ts", [], splices: [ imports: [~s|import "./runtime.js";|, ~s|import "./styles.css";|], setup: "registerPlugin(); startRuntime();" @@ -301,7 +301,9 @@ Volt.Priv.js!(@templates, "plugin-entry.ts", [], Splices use `OXC.splice/3`, so replacements are parsed as JavaScript syntax rather than inserted with textual replacement. A replacement can be an AST node, source or -iodata containing multiple nodes, or a list of those values. +iodata containing multiple nodes, or a list of those values. `render!/4` preserves +TypeScript for the subsequent Volt build; use `js!/4` when the caller needs compiled +browser JavaScript immediately. ### Example: AST transform with OXC @@ -516,3 +518,23 @@ end ``` The runtime automatically installs npm packages on first use and caches the bundled entry script. Subsequent calls reuse the running QuickBEAM instance. + +Frameworks that bundle package-owned browser modules without executing them in +QuickBEAM can install those dependencies directly and scope them to the framework's +source root: + +```elixir +packages = Volt.NPM.install!(%{"framework-runtime" => "1.2.3"}) + +Volt.Builder.build( + entry: "assets/app.ts", + node_modules: "assets/node_modules", + package_scopes: [ + {Application.app_dir(:my_framework, "priv/ts"), packages.node_modules} + ] +) +``` + +An import originating below the scoped source root checks the framework package +directory first. Imports from application modules continue to resolve from the +application's `node_modules`, so package names and versions cannot shadow one another. diff --git a/lib/volt/builder.ex b/lib/volt/builder.ex index b1e8354..bd63c68 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -40,6 +40,8 @@ defmodule Volt.Builder do * `:define` — compile-time replacements * `:node_modules` — path to node_modules (default: auto-detect) * `:resolve_dirs` — additional directories to resolve bare specifiers (e.g. `["deps"]`) + * `:package_scopes` — source-root/package-directory pairs. Bare imports originating + below a source root resolve from its package directory before global package roots. * `:name` — output base name (default: derived from entry filename) * `:aliases` — import alias map (e.g. `%{"@" => "assets/src"}`) * `:plugins` — list of `Volt.Plugin` modules. Plugin `resolve/2` and `load/1` hooks can provide virtual modules and virtual build entries. @@ -382,6 +384,7 @@ defmodule Volt.Builder do NPM.Resolution.PackageResolver.find_node_modules(entry_base(first_entry)) resolve_dirs = Keyword.get(opts, :resolve_dirs, []) |> Enum.map(&Path.expand/1) + package_scopes = opts |> Keyword.get(:package_scopes, []) |> normalize_package_scopes() loaders = Keyword.get(opts, :loaders, %{}) module_types = Keyword.get(opts, :module_types, %{}) import_source = opts |> Keyword.get(:import_source) |> to_string_or_nil() @@ -399,6 +402,7 @@ defmodule Volt.Builder do ctx = %Volt.Builder.Context{ node_modules: node_modules, resolve_dirs: resolve_dirs, + package_scopes: package_scopes, aliases: aliases, plugins: Keyword.get(opts, :plugins, []), external: external_set, @@ -767,6 +771,14 @@ defmodule Volt.Builder do end end + defp normalize_package_scopes(scopes) when is_map(scopes) or is_list(scopes) do + scopes + |> Enum.map(fn {source_root, package_dir} -> + {Path.expand(source_root), Path.expand(package_dir)} + end) + |> Enum.sort_by(fn {source_root, _package_dir} -> -byte_size(source_root) end) + end + defp normalize_external(externals) when is_map(externals) do set = externals |> Map.keys() |> MapSet.new() {set, externals} diff --git a/lib/volt/builder/context.ex b/lib/volt/builder/context.ex index 4dc4254..f6be16c 100644 --- a/lib/volt/builder/context.ex +++ b/lib/volt/builder/context.ex @@ -3,6 +3,7 @@ defmodule Volt.Builder.Context do defstruct node_modules: nil, resolve_dirs: [], + package_scopes: [], aliases: %{}, plugins: [], external: MapSet.new(), diff --git a/lib/volt/builder/resolver.ex b/lib/volt/builder/resolver.ex index 18c734b..b1edcdb 100644 --- a/lib/volt/builder/resolver.ex +++ b/lib/volt/builder/resolver.ex @@ -59,7 +59,7 @@ defmodule Volt.Builder.Resolver do resolve_relative(specifier, importer, ctx) true -> - resolve_bare(specifier, ctx.node_modules, ctx.resolve_dirs, ctx.plugins) + resolve_bare(specifier, importer, ctx) end end @@ -129,15 +129,38 @@ defmodule Volt.Builder.Resolver do Enum.any?(external, &String.starts_with?(specifier, &1 <> "/")) end - defp resolve_bare(specifier, node_modules, resolve_dirs, plugins) do - dirs = if node_modules, do: [node_modules | resolve_dirs], else: resolve_dirs + defp scoped_package_dirs(nil, _scopes), do: [] + + defp scoped_package_dirs(importer, scopes) do + {importer_path, _query} = Volt.URL.split_query(importer) + importer_path = Path.expand(importer_path) + + for {source_root, package_dir} <- scopes, + path_within?(importer_path, source_root), + do: package_dir + end + + defp path_within?(path, root) do + relative = Path.relative_to(path, root) + Path.type(relative) == :relative and not match?([".." | _rest], Path.split(relative)) + end + + defp resolve_bare(specifier, importer, ctx) do + global_dirs = + if ctx.node_modules, do: [ctx.node_modules | ctx.resolve_dirs], else: ctx.resolve_dirs + + dirs = + importer + |> scoped_package_dirs(ctx.package_scopes) + |> Kernel.++(global_dirs) + |> Enum.uniq() case Enum.find_value(dirs, fn dir -> {package_name, _subpath} = NPM.Resolution.PackageResolver.split_specifier(specifier) package_dir = Path.join(dir, package_name) if File.dir?(package_dir) do - resolve_in_package(specifier, dir, package_dir, plugins) || :unresolved_package + resolve_in_package(specifier, dir, package_dir, ctx.plugins) || :unresolved_package end end) do nil -> :skip diff --git a/lib/volt/npm.ex b/lib/volt/npm.ex new file mode 100644 index 0000000..17c15d0 --- /dev/null +++ b/lib/volt/npm.ex @@ -0,0 +1,18 @@ +defmodule Volt.NPM do + @moduledoc """ + Installs exact npm package sets into Volt's isolated, npm_ex-backed cache. + + Frameworks and plugins can use this API for JavaScript dependencies they own + without adding those packages to an application's `node_modules` directory. + The returned directory is stable for the normalized package set and can be + passed to `Volt.Builder` as a package directory or scoped package root. + """ + + @type install_result :: %{install_dir: String.t(), node_modules: String.t()} + + @doc "Install a package map and return its isolated cache directories." + @spec install!(%{String.t() => String.t()}, keyword()) :: install_result() + def install!(packages, opts \\ []) when is_map(packages) do + Volt.JS.Runtime.Installer.install!(packages, opts) + end +end diff --git a/lib/volt/priv.ex b/lib/volt/priv.ex index b0f77d0..0901a56 100644 --- a/lib/volt/priv.ex +++ b/lib/volt/priv.ex @@ -15,13 +15,14 @@ defmodule Volt.Priv do Volt.Priv.path(assets, "runtime.ts") Volt.Priv.read!(assets, "runtime.ts") + Volt.Priv.render!(assets, "entry.ts", [], splices: [body: "start();"]) Volt.Priv.js!(assets, "runtime.ts") Volt.Priv.js!(assets, "entry.ts", id: "counter", props: %{count: 1}) - `js!/3` binds `$placeholder` JavaScript literals with OXC before emitting browser - JavaScript. `js!/4` also supports statement, property, and array-element splices. - That keeps templates valid TypeScript or JavaScript files instead of requiring EEx - or textual source replacement. + `render!/4` binds and splices templates while preserving TypeScript for a later + Volt build. `js!/3` and `js!/4` additionally compile the rendered source to browser + JavaScript. This keeps templates valid TypeScript or JavaScript files instead of + requiring EEx or textual source replacement. """ @type source :: atom() | {atom(), String.t()} @@ -48,6 +49,23 @@ defmodule Volt.Priv do |> File.read!() end + @doc """ + Renders a JavaScript or TypeScript template stored under `priv`. + + Literal bindings use `OXC.bind/2`, while the `:splices` option accepts statement, + property, and array-element replacements handled by `OXC.splice/3`. The rendered + source is not compiled, making this API suitable for virtual modules and generated + entries that will subsequently pass through `Volt.Builder`. + """ + @spec render!(source(), String.t()) :: String.t() + @spec render!(source(), String.t(), bindings()) :: String.t() + @spec render!(source(), String.t(), bindings(), keyword()) :: String.t() + def render!(source, relative, bindings \\ [], opts \\ []) when is_binary(relative) do + source + |> render_source(relative, bindings, Keyword.get(opts, :splices, [])) + |> rewrite_specifiers(opts[:rewrite_specifiers], relative) + end + @doc """ Emits browser JavaScript for a support file stored under `priv`. @@ -74,41 +92,52 @@ defmodule Volt.Priv do defp js_source(source, relative, bindings, splices) do if empty?(bindings) and empty?(splices) do cached_js!(source, relative) + else + source + |> render_source(relative, bindings, splices) + |> compile(relative) + end + end + + defp render_source(source, relative, bindings, splices) do + if empty?(bindings) and empty?(splices) do + read!(source, relative) else source |> template_ast!(relative) |> OXC.bind(literal_bindings(bindings)) |> splice_all(splices) |> OXC.codegen!() - |> compile(relative) end end defp cached_js!(source, relative) do - key = {__MODULE__, :js, source, relative} + source_code = read!(source, relative) - case :persistent_term.get(key, nil) do - nil -> - code = source |> read!(relative) |> compile(relative) - :persistent_term.put(key, code) - code - - code -> - code - end + cached_value({__MODULE__, :js, source, relative}, source_code, fn -> + compile(source_code, relative) + end) end defp template_ast!(source, relative) do - key = {__MODULE__, :template_ast, source, relative} + source_code = read!(source, relative) + + cached_value({__MODULE__, :template_ast, source, relative}, source_code, fn -> + OXC.parse!(source_code, relative) + end) + end + + defp cached_value(key, source, build) do + fingerprint = {byte_size(source), :erlang.phash2(source)} case :persistent_term.get(key, nil) do - nil -> - ast = source |> read!(relative) |> OXC.parse!(relative) - :persistent_term.put(key, ast) - ast + {^fingerprint, value} -> + value - ast -> - ast + _stale_or_missing -> + value = build.() + :persistent_term.put(key, {fingerprint, value}) + value end end diff --git a/lib/volt/test/plugin.ex b/lib/volt/test/plugin.ex index e6f2a31..0bcd950 100644 --- a/lib/volt/test/plugin.ex +++ b/lib/volt/test/plugin.ex @@ -52,7 +52,7 @@ defmodule Volt.Test.Plugin do |> Enum.map(&"import #{inspect(&1)};") source = - Volt.Priv.js!({:volt, "ts"}, "test/entry.ts", [], splices: [imports: imports]) + Volt.Priv.render!({:volt, "ts"}, "test/entry.ts", [], splices: [imports: imports]) {:ok, source} end diff --git a/test/volt/builder/resolution_test.exs b/test/volt/builder/resolution_test.exs index a505fbe..df40d7a 100644 --- a/test/volt/builder/resolution_test.exs +++ b/test/volt/builder/resolution_test.exs @@ -84,6 +84,48 @@ defmodule Volt.Builder.ResolutionTest do assert js =~ "b-" end + test "resolves bare imports from the importer's scoped package directory" do + app_packages = Path.join(@fixture_dir, "node_modules") + framework_root = Path.join(@fixture_dir, "framework") + framework_packages = Path.join(framework_root, "node_modules") + + for {package_dir, value} <- [ + {Path.join(app_packages, "shared-pkg"), "app-package"}, + {Path.join(framework_packages, "shared-pkg"), "framework-package"} + ] do + File.mkdir_p!(package_dir) + File.write!(Path.join(package_dir, "package.json"), ~s({"main":"index.js"})) + File.write!(Path.join(package_dir, "index.js"), "export default #{inspect(value)};") + end + + File.mkdir_p!(framework_root) + + File.write!(Path.join(framework_root, "runtime.js"), """ + import value from 'shared-pkg' + export const frameworkValue = value + """) + + File.write!(Path.join(@fixture_dir, "src/scoped_packages_app.js"), """ + import appValue from 'shared-pkg' + import { frameworkValue } from '../framework/runtime.js' + console.log(appValue, frameworkValue) + """) + + {:ok, result} = + Volt.Builder.build( + entry: Path.join(@fixture_dir, "src/scoped_packages_app.js"), + outdir: @outdir, + minify: false, + sourcemap: false, + node_modules: app_packages, + package_scopes: [{framework_root, framework_packages}] + ) + + js = File.read!(result.js.path) + assert js =~ "app-package" + assert js =~ "framework-package" + end + test "resolves package imports from nearest package imports map" do File.mkdir_p!(Path.join(@fixture_dir, "node_modules/pkg/src/internal")) diff --git a/test/volt/priv_test.exs b/test/volt/priv_test.exs index cf95d54..b4b01c4 100644 --- a/test/volt/priv_test.exs +++ b/test/volt/priv_test.exs @@ -15,6 +15,13 @@ defmodule Volt.PrivTest do assert code =~ "const proto" end + test "renders TypeScript without compiling it" do + code = Volt.Priv.render!(@support_modules, "test/browser.ts") + + assert code =~ "type BrowserPayload" + assert code =~ "src: string" + end + test "emits browser JavaScript from TypeScript support modules" do code = Volt.Priv.js!(@support_modules, "client/hmr.ts") @@ -22,6 +29,18 @@ defmodule Volt.PrivTest do refute code =~ "type " end + test "invalidates cached support modules when their source changes" do + relative = "test/cache-#{System.unique_integer([:positive])}.ts" + path = Volt.Priv.path(@support_modules, relative) + on_exit(fn -> File.rm(path) end) + + File.write!(path, "export const value = 1;") + assert Volt.Priv.js!(@support_modules, relative) =~ "value = 1" + + File.write!(path, "export const value = 2;") + assert Volt.Priv.js!(@support_modules, relative) =~ "value = 2" + end + test "binds JavaScript literals and rewrites support imports" do code = Volt.Priv.js!( From 699882847a392b58f1242fbbecf6e85c1fa3a4be Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 15 Jul 2026 22:55:38 +0200 Subject: [PATCH 005/128] Preserve package scopes for transitive imports --- lib/volt/builder/resolver.ex | 2 +- test/volt/builder/resolution_test.exs | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/volt/builder/resolver.ex b/lib/volt/builder/resolver.ex index b1edcdb..d0504fd 100644 --- a/lib/volt/builder/resolver.ex +++ b/lib/volt/builder/resolver.ex @@ -136,7 +136,7 @@ defmodule Volt.Builder.Resolver do importer_path = Path.expand(importer_path) for {source_root, package_dir} <- scopes, - path_within?(importer_path, source_root), + path_within?(importer_path, source_root) or path_within?(importer_path, package_dir), do: package_dir end diff --git a/test/volt/builder/resolution_test.exs b/test/volt/builder/resolution_test.exs index df40d7a..cc8e9d4 100644 --- a/test/volt/builder/resolution_test.exs +++ b/test/volt/builder/resolution_test.exs @@ -98,6 +98,16 @@ defmodule Volt.Builder.ResolutionTest do File.write!(Path.join(package_dir, "index.js"), "export default #{inspect(value)};") end + framework_dep = Path.join(framework_packages, "framework-dep") + File.mkdir_p!(framework_dep) + File.write!(Path.join(framework_dep, "package.json"), ~s({"main":"index.js"})) + File.write!(Path.join(framework_dep, "index.js"), "export default 'scoped-transitive';") + + File.write!(Path.join(framework_packages, "shared-pkg/index.js"), """ + import dep from 'framework-dep' + export default 'framework-package-' + dep + """) + File.mkdir_p!(framework_root) File.write!(Path.join(framework_root, "runtime.js"), """ @@ -124,6 +134,7 @@ defmodule Volt.Builder.ResolutionTest do js = File.read!(result.js.path) assert js =~ "app-package" assert js =~ "framework-package" + assert js =~ "scoped-transitive" end test "resolves package imports from nearest package imports map" do From efb6dfc85c1fb23e472b81c4f2f2b9a6e4123775 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 15 Jul 2026 23:07:06 +0200 Subject: [PATCH 006/128] Prepare v0.17.3 release --- .github/workflows/ci.yml | 20 +++++++++++++ .reach.exs | 3 +- CHANGELOG.md | 7 ++++- README.md | 4 +-- lib/volt/npm.ex | 10 +++++++ mix.exs | 3 +- mix.lock | 2 +- priv/ts/test/template-globals.d.ts | 1 + test/volt/npm_test.exs | 9 ++++++ test/volt/test/ex_unit_test.exs | 46 ++++++++++++++++++------------ 10 files changed, 81 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 priv/ts/test/template-globals.d.ts create mode 100644 test/volt/npm_test.exs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..61add72 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + elixir: + uses: elixir-vibe/actions/.github/workflows/elixir-ci.yml@v1 + with: + latest-elixir: "1.20" + latest-otp: "29" + min-elixir: "1.17" + min-otp: "27" + min-command: mix compile --warnings-as-errors && mix test diff --git a/.reach.exs b/.reach.exs index 979759b..327aabb 100644 --- a/.reach.exs +++ b/.reach.exs @@ -10,13 +10,14 @@ compat_json = [":json.*"] unsafe_runtime_encoding = ["Jason.encode!"] unstable_hashing = [":erlang.phash2"] builder_runtime = ["Volt.DevServer.*", "Volt.Watcher.*", "Volt.HMR.*"] -asset_runtime = ["Volt.Assets.*", "Volt.Builder.*", "Volt.DevServer.*", "Volt.JS.*"] +asset_runtime = ["Volt.Assets.*", "Volt.Builder.*", "Volt.DevServer.*", "Volt.JS.*", "Volt.NPM"] ast_side_effects = app_config ++ file_mutation ++ ["System.cmd", "QuickBEAM.*"] ++ builder_runtime adapter = [ "Mix.Tasks.Volt.*", "Volt", "Volt.Formatter", + "Volt.NPM", "Volt.Test.*" ] diff --git a/CHANGELOG.md b/CHANGELOG.md index bc525a8..4689852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.17.3 - 2026-07-13 ### Added @@ -11,6 +11,11 @@ ### Fixed - Invalidate cached `Volt.Priv` templates and compiled modules when their source changes. +- Type-check the private test-entry splice placeholder without changing rendered output. + +### Changed + +- Verify the documented Elixir 1.17 and Erlang/OTP 27 minimum versions in CI. ## 0.17.2 - 2026-07-13 diff --git a/README.md b/README.md index 094f7be..6b8bc74 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ mix igniter.install volt mix phx.server ``` -The installer configures everything. No binaries to download, no extra processes to manage. +The installer configures everything. No binaries to download, no extra processes to manage. Volt requires Elixir 1.17 or later and Erlang/OTP 27 or later. ## Why Volt @@ -31,7 +31,7 @@ Or add the dep manually: ```elixir def deps do - [{:volt, "~> 0.16"}] + [{:volt, "~> 0.17"}] end ``` diff --git a/lib/volt/npm.ex b/lib/volt/npm.ex index 17c15d0..6a65abf 100644 --- a/lib/volt/npm.ex +++ b/lib/volt/npm.ex @@ -13,6 +13,16 @@ defmodule Volt.NPM do @doc "Install a package map and return its isolated cache directories." @spec install!(%{String.t() => String.t()}, keyword()) :: install_result() def install!(packages, opts \\ []) when is_map(packages) do + Enum.each(packages, fn + {name, version} + when is_binary(name) and name != "" and is_binary(version) and version != "" -> + :ok + + package -> + raise ArgumentError, + "expected npm packages to contain non-empty string names and versions, got: #{inspect(package)}" + end) + Volt.JS.Runtime.Installer.install!(packages, opts) end end diff --git a/mix.exs b/mix.exs index db229b3..0b93ac6 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.17.2" + @version "0.17.3" @source_url "https://github.com/elixir-volt/volt" def project do @@ -136,6 +136,7 @@ defmodule Volt.MixProject do Volt.Env, Volt.JS.Runtime, Volt.JS.Format, + Volt.NPM, Volt.Priv ], Formatting: [Volt.Formatter, Volt.Format], diff --git a/mix.lock b/mix.lock index ac1f834..06cff45 100644 --- a/mix.lock +++ b/mix.lock @@ -40,7 +40,7 @@ "npm": {:hex, :npm, "0.7.5", "d8680384b4116a29270cd1fb706da017d1be3870d7d2993de382140d7b98d378", [: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", "174f6b230669f08240a87bef76f3d9ed03e03c126fea6ea4d1a3011a1681a95f"}, "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, - "oxc": {:hex, :oxc, "0.17.3", "2274c7bc6184bb65d1967a23f8c6ee5606dc99031882fe9e598f4184c92c2d01", [: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", "9fbe12f853bf6a517acf74c6f90aae18b71f9cf288b5c3bc267df386960c3496"}, + "oxc": {:hex, :oxc, "0.17.4", "471cd50396f46b7771bf476f45af8ace529e55ff656ca82680e04a1abdae20c5", [: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", "c20be17b0f9b3eeeaa96b4e7d975c755abd0a823f65550291d74adc28c59d96c"}, "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"}, "pegasus": {:hex, :pegasus, "0.2.6", "b4af6522326fbb2ffd1bb706e78ec05854fbcb4b03a7fe08f8db2e9de1d0be67", [:mix], [{:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0ac159f0ccab7967cf90208327cc8a35788874814c8d78e19d47104d3fc049b9"}, "pi_bridge": {:hex, :pi_bridge, "0.6.21", "f1a63da5f49fef4f6b35e98edd5400cbd64a49fb8bda84be41b7e91f3deb0b89", [:mix], [{:bandit, "~> 1.8", [hex: :bandit, repo: "hexpm", optional: false]}, {:dune, "~> 0.3", [hex: :dune, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_ast, "~> 0.12", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.1.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:quackdb, "~> 0.5.4", [hex: :quackdb, repo: "hexpm", optional: false]}, {:reach, "~> 2.6", [hex: :reach, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:req_llm, "~> 1.6", [hex: :req_llm, repo: "hexpm", optional: true]}], "hexpm", "b226fb60965a8e928186af3c3b96fe8f0fefd8c6cc7be679d6e169ba84105de9"}, diff --git a/priv/ts/test/template-globals.d.ts b/priv/ts/test/template-globals.d.ts new file mode 100644 index 0000000..aaf1db3 --- /dev/null +++ b/priv/ts/test/template-globals.d.ts @@ -0,0 +1 @@ +declare const $imports: unknown diff --git a/test/volt/npm_test.exs b/test/volt/npm_test.exs new file mode 100644 index 0000000..3395ec7 --- /dev/null +++ b/test/volt/npm_test.exs @@ -0,0 +1,9 @@ +defmodule Volt.NPMTest do + use ExUnit.Case, async: true + + test "install!/2 rejects invalid package entries before installation" do + assert_raise ArgumentError, ~r/non-empty string names and versions/, fn -> + Volt.NPM.install!(%{"package" => ""}) + end + end +end diff --git a/test/volt/test/ex_unit_test.exs b/test/volt/test/ex_unit_test.exs index a2f228c..c630e38 100644 --- a/test/volt/test/ex_unit_test.exs +++ b/test/volt/test/ex_unit_test.exs @@ -29,18 +29,22 @@ defmodule Volt.Test.ExUnitTest do assert Code.ensure_loaded?(module) test_module = module.__ex_unit__() - tests = Enum.sort_by(test_module.tests, & &1.description) + tests = Enum.sort_by(test_module.tests, &test_description/1) assert length(tests) == 2 - assert Enum.map(tests, & &1.description) == ["test math › adds", "test math › multiplies"] + + assert Enum.map(tests, &test_description/1) == [ + "test math › adds", + "test math › multiplies" + ] for test <- tests do - assert test.tags.js == true - assert test.tags.volt_file == file - assert test.tags.volt_test_id in [1, 2] - assert test.tags.file == file - assert test.tags.line in [4, 5] - assert test.tags.test_type == :test + assert test_tag(test, :js) == true + assert test_tag(test, :volt_file) == file + assert test_tag(test, :volt_test_id) in [1, 2] + assert test_tag(test, :file) == file + assert test_tag(test, :line) in [4, 5] + assert test_tag(test, :test_type) == :test end end @@ -56,15 +60,15 @@ defmodule Volt.Test.ExUnitTest do assert [module] = Volt.Test.ExUnit.install(root: tmp_dir, include: ["modifiers.test.ts"]) - tests = Enum.sort_by(module.__ex_unit__().tests, & &1.description) - skipped = Enum.find(tests, &(&1.description == "test skipped")) - tagged = Enum.find(tests, &(&1.description == "test tagged")) - todo = Enum.find(tests, &(&1.description == "test todo")) + tests = Enum.sort_by(module.__ex_unit__().tests, &test_description/1) + skipped = Enum.find(tests, &(test_description(&1) == "test skipped")) + tagged = Enum.find(tests, &(test_description(&1) == "test tagged")) + todo = Enum.find(tests, &(test_description(&1) == "test todo")) - assert skipped.tags.skip == "Skipped" - assert todo.tags.skip == "TODO" - assert tagged.tags.volt_tags == ["slow", "browser"] - assert tagged.tags.volt_file == file + assert test_tag(skipped, :skip) == "Skipped" + assert test_tag(todo, :skip) == "TODO" + assert test_tag(tagged, :volt_tags) == ["slow", "browser"] + assert test_tag(tagged, :volt_file) == file end test "generated ExUnit test functions execute a single JS test", %{tmp_dir: tmp_dir} do @@ -76,9 +80,9 @@ defmodule Volt.Test.ExUnitTest do """) assert [module] = Volt.Test.ExUnit.install(root: tmp_dir, include: ["single.test.ts"]) - [%ExUnit.Test{name: name}] = module.__ex_unit__().tests + [test] = module.__ex_unit__().tests - assert :ok = apply(module, name, [%{}]) + assert :ok = apply(module, test_name(test), [%{}]) assert file end @@ -90,6 +94,12 @@ defmodule Volt.Test.ExUnitTest do end end + defp test_description(test), do: Map.fetch!(test, :description) + defp test_name(test), do: Map.fetch!(test, :name) + + defp test_tag(%{tags: tags}, key), do: Map.fetch!(tags, key) + defp test_tag(test, key), do: Map.fetch!(test, key) + defp write!(root, path, contents) do path = Path.join(root, path) File.mkdir_p!(Path.dirname(path)) From 00254779f54f8468eb65eb2dc6d8f83db9b7cbe7 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 15 Jul 2026 23:28:15 +0200 Subject: [PATCH 007/128] Provision minimum-version CI dependencies --- .github/workflows/ci.yml | 7 +++++++ test/volt/test/ex_unit_test.exs | 1 + 2 files changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61add72..b023c2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,4 +17,11 @@ jobs: latest-otp: "29" min-elixir: "1.17" min-otp: "27" + deps-command: | + sudo apt-get update + sudo apt-get install --yes inotify-tools + mix deps.get + npm ci + npx playwright install --with-deps chromium + latest-command: MIX_ENV=dev mix lint && MIX_ENV=test mix test min-command: mix compile --warnings-as-errors && mix test diff --git a/test/volt/test/ex_unit_test.exs b/test/volt/test/ex_unit_test.exs index c630e38..99c84c5 100644 --- a/test/volt/test/ex_unit_test.exs +++ b/test/volt/test/ex_unit_test.exs @@ -94,6 +94,7 @@ defmodule Volt.Test.ExUnitTest do end end + defp test_description(%ExUnit.Test{name: name}), do: Atom.to_string(name) defp test_description(test), do: Map.fetch!(test, :description) defp test_name(test), do: Map.fetch!(test, :name) From 2dc9fed59c168c4b1f03710d066898faed765266 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 00:13:19 +0200 Subject: [PATCH 008/128] Require Elixir 1.18 for Volt 0.17.3 --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 2 +- README.md | 2 +- mix.exs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b023c2e..b8d5327 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: with: latest-elixir: "1.20" latest-otp: "29" - min-elixir: "1.17" + min-elixir: "1.18" min-otp: "27" deps-command: | sudo apt-get update diff --git a/CHANGELOG.md b/CHANGELOG.md index 4689852..568168d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ ### Changed -- Verify the documented Elixir 1.17 and Erlang/OTP 27 minimum versions in CI. +- Require Elixir 1.18 and Erlang/OTP 27, and verify those minimum versions in CI. ## 0.17.2 - 2026-07-13 diff --git a/README.md b/README.md index 6b8bc74..b821e95 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ mix igniter.install volt mix phx.server ``` -The installer configures everything. No binaries to download, no extra processes to manage. Volt requires Elixir 1.17 or later and Erlang/OTP 27 or later. +The installer configures everything. No binaries to download, no extra processes to manage. Volt requires Elixir 1.18 or later and Erlang/OTP 27 or later. ## Why Volt diff --git a/mix.exs b/mix.exs index 0b93ac6..b3ce3a2 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule Volt.MixProject do [ app: :volt, version: @version, - elixir: "~> 1.17", + elixir: "~> 1.18", start_permanent: Mix.env() == :prod, deps: deps(), aliases: aliases(), From 352ec47ae0191bee38df86911040a5b5f6b6aa22 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 00:27:10 +0200 Subject: [PATCH 009/128] Document transitive package scope fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 568168d..ee543d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed +- Preserve importer package scopes while resolving transitive package imports. - Invalidate cached `Volt.Priv` templates and compiled modules when their source changes. - Type-check the private test-entry splice placeholder without changing rendered output. From c0be5d0dbae370be6e4bc7f7a810a3ebfec05d4c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 11:04:50 +0200 Subject: [PATCH 010/128] Support tool-specific JavaScript source sets --- CHANGELOG.md | 6 +++ guides/features/formatting-and-linting.md | 7 ++++ lib/mix/tasks/volt.js.check.ex | 2 +- lib/mix/tasks/volt.lint.ex | 2 +- lib/volt/js/check.ex | 2 + lib/volt/js/format.ex | 3 +- lib/volt/js/helpers.ex | 12 ++++-- mix.exs | 4 +- mix.lock | 6 +-- test/mix/tasks/volt.js.check_test.exs | 15 ++++++++ test/volt/js/format_test.exs | 28 ++++++++++++++ test/volt/js/helpers_test.exs | 46 +++++++++++++++++++++++ 12 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 test/volt/js/format_test.exs create mode 100644 test/volt/js/helpers_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index ee543d0..c825f4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.17.4 - 2026-07-13 + +### Added + +- Allow formatter and linter file discovery to override build `:root`, `:sources`, and `:ignore` independently. + ## 0.17.3 - 2026-07-13 ### Added diff --git a/guides/features/formatting-and-linting.md b/guides/features/formatting-and-linting.md index de1e42b..97857f7 100644 --- a/guides/features/formatting-and-linting.md +++ b/guides/features/formatting-and-linting.md @@ -36,6 +36,8 @@ config :volt, :format, All [oxfmt options](https://hexdocs.pm/oxc/OXC.Format.html) are supported. Falls back to `.oxfmtrc.json` if no Elixir config is set. +`:root`, `:sources`, and `:ignore` may also be set under `config :volt, :format` to override the build source set for formatting only. + ## Linting Lint JS/TS assets using oxlint via NIF — 650+ rules, no Node.js required: @@ -51,6 +53,9 @@ Available plugins: `react`, `typescript`, `unicorn`, `import`, `jsdoc`, `jest`, ```elixir config :volt, :lint, + sources: ["priv/ts/**/*.ts", "test/javascript/**/*.mjs"], + ignore: ["test/javascript/fixtures/**"], + globals: %{"describe" => :readonly, "it" => :readonly}, plugins: [:typescript], rules: %{ "no-debugger" => :deny, @@ -59,6 +64,8 @@ config :volt, :lint, } ``` +Lint-specific `:root`, `:sources`, and `:ignore` values override the build source set without changing which files the formatter checks. This is useful when canonical fixtures must be linted but retain their original formatting. + ### Custom Rules Custom lint rules can be written in Elixir using the `OXC.Lint.Rule` behaviour — see the [oxc docs](https://hexdocs.pm/oxc/OXC.Lint.Rule.html). diff --git a/lib/mix/tasks/volt.js.check.ex b/lib/mix/tasks/volt.js.check.ex index e78d7a1..701573b 100644 --- a/lib/mix/tasks/volt.js.check.ex +++ b/lib/mix/tasks/volt.js.check.ex @@ -23,7 +23,7 @@ defmodule Mix.Tasks.Volt.Js.Check do opts = parse_args!(args) format_files = Volt.JS.Helpers.discover_format_files() - lint_files = Volt.JS.Helpers.discover_files() + lint_files = Volt.JS.Helpers.discover_files(tool: :lint) if format_files == [] and lint_files == [] do Mix.shell().info("No files found") diff --git a/lib/mix/tasks/volt.lint.ex b/lib/mix/tasks/volt.lint.ex index 2b5372e..fd17bb2 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(only: ~w(.js .ts .jsx .tsx)) + files = Volt.JS.Helpers.discover_files(tool: :lint, only: ~w(.js .ts .jsx .tsx)) if files == [] do Mix.shell().info("No lintable files found") diff --git a/lib/volt/js/check.ex b/lib/volt/js/check.ex index 81c91f2..f707b23 100644 --- a/lib/volt/js/check.ex +++ b/lib/volt/js/check.ex @@ -58,6 +58,7 @@ defmodule Volt.JS.Check do defp ast_lint(files, config, rules) do plugins = Keyword.get(config, :plugins, [:typescript]) custom_rules = Keyword.get(config, :custom_rules, []) + globals = Keyword.get(config, :globals, %{}) Enum.flat_map(files, fn file -> source = File.read!(file) @@ -65,6 +66,7 @@ defmodule Volt.JS.Check do case OXC.Lint.run(source, file, plugins: plugins, rules: rules, + globals: globals, custom_rules: custom_rules ) do {:ok, diagnostics} -> Enum.map(diagnostics, &Map.put(&1, :file, file)) diff --git a/lib/volt/js/format.ex b/lib/volt/js/format.ex index af691a7..9743f34 100644 --- a/lib/volt/js/format.ex +++ b/lib/volt/js/format.ex @@ -24,11 +24,12 @@ defmodule Volt.JS.Format do } @atom_values ~w(trailing_comma arrow_parens end_of_line quote_props object_wrap experimental_operator_position embedded_language_formatting)a + @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) -> opts + opts when is_list(opts) -> Keyword.drop(opts, @discovery_keys) end end diff --git a/lib/volt/js/helpers.ex b/lib/volt/js/helpers.ex index 1198836..a092c94 100644 --- a/lib/volt/js/helpers.ex +++ b/lib/volt/js/helpers.ex @@ -3,9 +3,10 @@ defmodule Volt.JS.Helpers do def discover_files(opts \\ []) do config = Volt.Config.build() - root = config.root - sources = config.sources - ignore = config.ignore + tool_config = discovery_config(Keyword.get(opts, :tool)) + root = Keyword.get(tool_config, :root, config.root) + sources = Keyword.get(tool_config, :sources, config.sources) + ignore = Keyword.get(tool_config, :ignore, config.ignore) only = Keyword.get(opts, :only) matched = @@ -33,6 +34,9 @@ defmodule Volt.JS.Helpers do end def discover_format_files do - discover_files(only: Volt.JS.Extensions.formattable()) + discover_files(tool: :format, only: Volt.JS.Extensions.formattable()) end + + defp discovery_config(nil), do: [] + defp discovery_config(tool), do: Application.get_env(:volt, tool, []) end diff --git a/mix.exs b/mix.exs index b3ce3a2..2554bd9 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.17.3" + @version "0.17.4" @source_url "https://github.com/elixir-volt/volt" def project do @@ -35,7 +35,7 @@ defmodule Volt.MixProject do [ {:reach, "~> 2.6.1", only: [:dev, :test], runtime: false}, {:glob_ex, "~> 0.1"}, - {:oxc, "~> 0.17.3"}, + {:oxc, "~> 0.17.5"}, {:vize, "~> 0.14.0"}, {:oxide_ex, "~> 0.2.1"}, {:quickbeam, "~> 0.10.20"}, diff --git a/mix.lock b/mix.lock index 06cff45..42c1584 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,7 @@ %{ "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"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"}, + "castore": {:hex, :castore, "1.0.20", "455e48f7115eca98c9f2b0e7a152b5a2e8f2a8a4f964c96e95bd31645ee5fa59", [:mix], [], "hexpm", "940eafbfd8b14bee649f083bc11b3b54ec555b54c3e4ea8213351ff6fee39c10"}, "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"}, "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, @@ -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.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": {: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_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"}, "muontrap": {:hex, :muontrap, "1.8.0", "3c5dab5bc91d5a16d14cd85b130ae7d35a9c469564a11ba4b2ebbcd7ccf5bc05", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "45ff36451303cbdefe1c66da3681b5aff078ee5737348f554f0017daa66284af"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, @@ -40,7 +40,7 @@ "npm": {:hex, :npm, "0.7.5", "d8680384b4116a29270cd1fb706da017d1be3870d7d2993de382140d7b98d378", [: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", "174f6b230669f08240a87bef76f3d9ed03e03c126fea6ea4d1a3011a1681a95f"}, "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, - "oxc": {:hex, :oxc, "0.17.4", "471cd50396f46b7771bf476f45af8ace529e55ff656ca82680e04a1abdae20c5", [: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", "c20be17b0f9b3eeeaa96b4e7d975c755abd0a823f65550291d74adc28c59d96c"}, + "oxc": {:hex, :oxc, "0.17.5", "870fdde7aa23f2b55a79a56e19fdaa70731de7c28e573b5d3be66b198a612483", [: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", "4b1a2f906fed9783816ff0449af87e9733e8c7ba04ad5118c0cdac077f1a4a8e"}, "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"}, "pegasus": {:hex, :pegasus, "0.2.6", "b4af6522326fbb2ffd1bb706e78ec05854fbcb4b03a7fe08f8db2e9de1d0be67", [:mix], [{:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0ac159f0ccab7967cf90208327cc8a35788874814c8d78e19d47104d3fc049b9"}, "pi_bridge": {:hex, :pi_bridge, "0.6.21", "f1a63da5f49fef4f6b35e98edd5400cbd64a49fb8bda84be41b7e91f3deb0b89", [:mix], [{:bandit, "~> 1.8", [hex: :bandit, repo: "hexpm", optional: false]}, {:dune, "~> 0.3", [hex: :dune, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_ast, "~> 0.12", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.1.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:quackdb, "~> 0.5.4", [hex: :quackdb, repo: "hexpm", optional: false]}, {:reach, "~> 2.6", [hex: :reach, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:req_llm, "~> 1.6", [hex: :req_llm, repo: "hexpm", optional: true]}], "hexpm", "b226fb60965a8e928186af3c3b96fe8f0fefd8c6cc7be679d6e169ba84105de9"}, diff --git a/test/mix/tasks/volt.js.check_test.exs b/test/mix/tasks/volt.js.check_test.exs index 39484c3..5bb73eb 100644 --- a/test/mix/tasks/volt.js.check_test.exs +++ b/test/mix/tasks/volt.js.check_test.exs @@ -42,6 +42,21 @@ defmodule Volt.JsCheckTest do assert output =~ "broken.ts" end + test "syntax lint recognizes configured globals" do + file = Path.join(@tmp_dir, "globals.js") + File.write!(file, "knownGlobal(); missingGlobal();\n") + + Application.put_env(:volt, :lint, + globals: %{"knownGlobal" => :readonly}, + rules: %{"no-undef" => :deny} + ) + + diagnostics = Volt.JS.Check.lint([file]) + + assert Enum.any?(diagnostics, &(&1.message =~ "missingGlobal")) + refute Enum.any?(diagnostics, &(&1.message =~ "knownGlobal")) + end + test "type-aware check reports tsgolint diagnostics" do File.write!(Path.join(@tmp_dir, "typed.ts"), "export const value = Promise.resolve(1)\n") tsgolint = fake_tsgolint!(@tmp_dir) diff --git a/test/volt/js/format_test.exs b/test/volt/js/format_test.exs new file mode 100644 index 0000000..2cb7794 --- /dev/null +++ b/test/volt/js/format_test.exs @@ -0,0 +1,28 @@ +defmodule Volt.JS.FormatTest do + use ExUnit.Case, async: false + + alias Volt.JS.Format + + setup do + original_config = Application.get_env(:volt, :format) + + on_exit(fn -> + case original_config do + nil -> Application.delete_env(:volt, :format) + config -> Application.put_env(:volt, :format, config) + end + end) + end + + test "load_config/0 excludes file-discovery options from OXC formatter options" do + Application.put_env(:volt, :format, + root: ".", + sources: ["priv/ts/**/*.ts"], + ignore: ["vendor/**"], + semi: false, + print_width: 100 + ) + + assert Format.load_config() == [semi: false, print_width: 100] + end +end diff --git a/test/volt/js/helpers_test.exs b/test/volt/js/helpers_test.exs new file mode 100644 index 0000000..a672b18 --- /dev/null +++ b/test/volt/js/helpers_test.exs @@ -0,0 +1,46 @@ +defmodule Volt.JS.HelpersTest do + use ExUnit.Case, async: false + + alias Volt.JS.Helpers + + setup do + tmp_dir = + Path.join(System.tmp_dir!(), "volt-js-helpers-#{System.unique_integer([:positive])}") + + original_format = Application.get_env(:volt, :format) + original_lint = Application.get_env(:volt, :lint) + + File.mkdir_p!(Path.join(tmp_dir, "format")) + File.mkdir_p!(Path.join(tmp_dir, "lint")) + File.write!(Path.join(tmp_dir, "format/source.ts"), "const formatted = true") + File.write!(Path.join(tmp_dir, "lint/source.ts"), "const linted = true") + + on_exit(fn -> + restore_env(:format, original_format) + restore_env(:lint, original_lint) + File.rm_rf!(tmp_dir) + end) + + %{tmp_dir: tmp_dir} + end + + test "discovers tool-specific source sets", %{tmp_dir: tmp_dir} do + Application.put_env(:volt, :format, + root: tmp_dir, + sources: ["format/**/*.ts"], + ignore: [] + ) + + Application.put_env(:volt, :lint, + root: tmp_dir, + sources: ["lint/**/*.ts"], + ignore: [] + ) + + assert Helpers.discover_format_files() == [Path.join(tmp_dir, "format/source.ts")] + assert Helpers.discover_files(tool: :lint) == [Path.join(tmp_dir, "lint/source.ts")] + end + + defp restore_env(key, nil), do: Application.delete_env(:volt, key) + defp restore_env(key, value), do: Application.put_env(:volt, key, value) +end From caf3d43a85d59af7b6e2a74d008c440859c85782 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 15:08:58 +0200 Subject: [PATCH 011/128] Support Oxlint environments --- CHANGELOG.md | 6 ++++++ guides/features/formatting-and-linting.md | 3 ++- lib/volt/js/check.ex | 2 ++ mix.exs | 2 +- mix.lock | 2 +- test/mix/tasks/volt.js.check_test.exs | 5 ++++- 6 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c825f4a..5abb7e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Added + +- Pass configured Oxlint environments to OXC linting. + ## 0.17.4 - 2026-07-13 ### Added diff --git a/guides/features/formatting-and-linting.md b/guides/features/formatting-and-linting.md index 97857f7..f5f0a6a 100644 --- a/guides/features/formatting-and-linting.md +++ b/guides/features/formatting-and-linting.md @@ -55,7 +55,8 @@ Available plugins: `react`, `typescript`, `unicorn`, `import`, `jsdoc`, `jest`, config :volt, :lint, sources: ["priv/ts/**/*.ts", "test/javascript/**/*.mjs"], ignore: ["test/javascript/fixtures/**"], - globals: %{"describe" => :readonly, "it" => :readonly}, + env: [:browser, :node, :mocha], + globals: %{"AppRuntime" => :readonly}, plugins: [:typescript], rules: %{ "no-debugger" => :deny, diff --git a/lib/volt/js/check.ex b/lib/volt/js/check.ex index f707b23..f5033f8 100644 --- a/lib/volt/js/check.ex +++ b/lib/volt/js/check.ex @@ -58,6 +58,7 @@ defmodule Volt.JS.Check do defp ast_lint(files, config, rules) do plugins = Keyword.get(config, :plugins, [:typescript]) custom_rules = Keyword.get(config, :custom_rules, []) + env = Keyword.get(config, :env, []) globals = Keyword.get(config, :globals, %{}) Enum.flat_map(files, fn file -> @@ -66,6 +67,7 @@ defmodule Volt.JS.Check do case OXC.Lint.run(source, file, plugins: plugins, rules: rules, + env: env, globals: globals, custom_rules: custom_rules ) do diff --git a/mix.exs b/mix.exs index 2554bd9..1bf8a02 100644 --- a/mix.exs +++ b/mix.exs @@ -35,7 +35,7 @@ defmodule Volt.MixProject do [ {:reach, "~> 2.6.1", only: [:dev, :test], runtime: false}, {:glob_ex, "~> 0.1"}, - {:oxc, "~> 0.17.5"}, + {:oxc, "~> 0.17.6"}, {:vize, "~> 0.14.0"}, {:oxide_ex, "~> 0.2.1"}, {:quickbeam, "~> 0.10.20"}, diff --git a/mix.lock b/mix.lock index 42c1584..d041a6e 100644 --- a/mix.lock +++ b/mix.lock @@ -40,7 +40,7 @@ "npm": {:hex, :npm, "0.7.5", "d8680384b4116a29270cd1fb706da017d1be3870d7d2993de382140d7b98d378", [: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", "174f6b230669f08240a87bef76f3d9ed03e03c126fea6ea4d1a3011a1681a95f"}, "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, - "oxc": {:hex, :oxc, "0.17.5", "870fdde7aa23f2b55a79a56e19fdaa70731de7c28e573b5d3be66b198a612483", [: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", "4b1a2f906fed9783816ff0449af87e9733e8c7ba04ad5118c0cdac077f1a4a8e"}, + "oxc": {:hex, :oxc, "0.17.6", "470b24662de156f749f48d16ae2b3cd2d8916e8fe10467e18267debe87d914d4", [: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", "b25421081f9b7ec1345210794b28e5c2cc05e37c83368c935e71c8100d150347"}, "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"}, "pegasus": {:hex, :pegasus, "0.2.6", "b4af6522326fbb2ffd1bb706e78ec05854fbcb4b03a7fe08f8db2e9de1d0be67", [:mix], [{:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0ac159f0ccab7967cf90208327cc8a35788874814c8d78e19d47104d3fc049b9"}, "pi_bridge": {:hex, :pi_bridge, "0.6.21", "f1a63da5f49fef4f6b35e98edd5400cbd64a49fb8bda84be41b7e91f3deb0b89", [:mix], [{:bandit, "~> 1.8", [hex: :bandit, repo: "hexpm", optional: false]}, {:dune, "~> 0.3", [hex: :dune, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_ast, "~> 0.12", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.1.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:quackdb, "~> 0.5.4", [hex: :quackdb, repo: "hexpm", optional: false]}, {:reach, "~> 2.6", [hex: :reach, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:req_llm, "~> 1.6", [hex: :req_llm, repo: "hexpm", optional: true]}], "hexpm", "b226fb60965a8e928186af3c3b96fe8f0fefd8c6cc7be679d6e169ba84105de9"}, diff --git a/test/mix/tasks/volt.js.check_test.exs b/test/mix/tasks/volt.js.check_test.exs index 5bb73eb..581e0d4 100644 --- a/test/mix/tasks/volt.js.check_test.exs +++ b/test/mix/tasks/volt.js.check_test.exs @@ -44,9 +44,10 @@ defmodule Volt.JsCheckTest do test "syntax lint recognizes configured globals" do file = Path.join(@tmp_dir, "globals.js") - File.write!(file, "knownGlobal(); missingGlobal();\n") + File.write!(file, "document; describe; knownGlobal(); missingGlobal();\n") Application.put_env(:volt, :lint, + env: [:browser, :mocha], globals: %{"knownGlobal" => :readonly}, rules: %{"no-undef" => :deny} ) @@ -54,6 +55,8 @@ defmodule Volt.JsCheckTest do diagnostics = Volt.JS.Check.lint([file]) assert Enum.any?(diagnostics, &(&1.message =~ "missingGlobal")) + refute Enum.any?(diagnostics, &(&1.message =~ "document")) + refute Enum.any?(diagnostics, &(&1.message =~ "describe")) refute Enum.any?(diagnostics, &(&1.message =~ "knownGlobal")) end From dd3397fce06b60592a66bcffaf8a30746ab0734d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 15:12:05 +0200 Subject: [PATCH 012/128] Prepare v0.17.5 release --- CHANGELOG.md | 2 +- mix.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5abb7e8..25ef506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.17.5 - 2026-07-13 ### Added diff --git a/mix.exs b/mix.exs index 1bf8a02..2d0f2ea 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.17.4" + @version "0.17.5" @source_url "https://github.com/elixir-volt/volt" def project do From 0ec3d2c3485788dd822a20d847ece78be5a9ba16 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 20:12:41 +0200 Subject: [PATCH 013/128] Describe strict production build result types --- CHANGELOG.md | 6 ++++++ lib/volt/builder.ex | 10 +--------- lib/volt/builder/manifest_entry.ex | 10 ++++++++++ lib/volt/builder/output/file.ex | 8 ++++++++ lib/volt/builder/result.ex | 7 +++++++ 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25ef506..ec5849b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +- Describe production build results with their strict `Volt.Builder.Result`, `ManifestEntry`, and `OutputFile` struct types. + ## 0.17.5 - 2026-07-13 ### Added diff --git a/lib/volt/builder.ex b/lib/volt/builder.ex index bd63c68..d8b2044 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -18,14 +18,6 @@ defmodule Volt.Builder do @css_import_noop "data:text/javascript,export{}" @dynamic_css_import_noop "Promise.resolve({ default: undefined })" - @type build_result :: %{ - js: - %{path: String.t(), size: non_neg_integer()} - | [%{path: String.t(), size: non_neg_integer()}], - css: %{path: String.t(), size: non_neg_integer()} | nil, - manifest: %{String.t() => String.t()} - } - @doc """ Build production assets from one or more entry files. @@ -59,7 +51,7 @@ defmodule Volt.Builder do external: ["vue", "phoenix"] external: %{"vue" => "Vue", "phoenix" => "Phoenix"} """ - @spec build(keyword()) :: {:ok, build_result()} | {:error, term()} + @spec build(keyword()) :: {:ok, Volt.Builder.Result.t()} | {:error, term()} def build(opts) do plugins = Keyword.get(opts, :plugins, []) diff --git a/lib/volt/builder/manifest_entry.ex b/lib/volt/builder/manifest_entry.ex index d436718..ce5aa16 100644 --- a/lib/volt/builder/manifest_entry.ex +++ b/lib/volt/builder/manifest_entry.ex @@ -10,6 +10,16 @@ defmodule Volt.Builder.ManifestEntry do css: [], assets: [] + @type t :: %__MODULE__{ + file: String.t(), + src: String.t(), + isEntry: boolean(), + imports: [String.t()], + dynamicImports: [String.t()], + css: [String.t()], + assets: [String.t()] + } + def js(src, file, opts \\ []) do %__MODULE__{src: src, file: file, isEntry: Keyword.get(opts, :entry, false)} end diff --git a/lib/volt/builder/output/file.ex b/lib/volt/builder/output/file.ex index ee00a3c..dfae82a 100644 --- a/lib/volt/builder/output/file.ex +++ b/lib/volt/builder/output/file.ex @@ -6,4 +6,12 @@ defmodule Volt.Builder.OutputFile do assets: [], chunk_id: nil, type: nil + + @type t :: %__MODULE__{ + path: String.t(), + size: non_neg_integer(), + assets: [term()], + chunk_id: String.t() | nil, + type: :entry | :chunk | nil + } end diff --git a/lib/volt/builder/result.ex b/lib/volt/builder/result.ex index 8463817..3dac361 100644 --- a/lib/volt/builder/result.ex +++ b/lib/volt/builder/result.ex @@ -3,6 +3,13 @@ defmodule Volt.Builder.Result do defstruct js: [], css: nil, manifest: %{}, chunks: [] + @type t :: %__MODULE__{ + js: Volt.Builder.OutputFile.t() | [Volt.Builder.OutputFile.t()], + css: Volt.Builder.OutputFile.t() | nil, + manifest: %{String.t() => Volt.Builder.ManifestEntry.t()}, + chunks: [Volt.Builder.OutputFile.t()] + } + @behaviour Access @impl Access From 12cfb36ce8341d62081f5230aeeeabcd0e520f3b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 20:29:59 +0200 Subject: [PATCH 014/128] Prepare v0.17.6 release --- CHANGELOG.md | 2 +- mix.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec5849b..dcd581b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.17.6 - 2026-07-16 ### Fixed diff --git a/mix.exs b/mix.exs index 2d0f2ea..2073831 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.17.5" + @version "0.17.6" @source_url "https://github.com/elixir-volt/volt" def project do From 1c8da3d6ed2c830f6c1d7e47ae269614e84a53e9 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 21:58:09 +0200 Subject: [PATCH 015/128] Use native OXC pipeline for private templates --- CHANGELOG.md | 6 ++++++ lib/volt/priv.ex | 25 +++++++++++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd581b..0429843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Improved + +- Render splice-only private templates through OXC's native pipeline to avoid repeated ESTree serialization for large generated entries. + ## 0.17.6 - 2026-07-16 ### Fixed diff --git a/lib/volt/priv.ex b/lib/volt/priv.ex index 0901a56..3e8e699 100644 --- a/lib/volt/priv.ex +++ b/lib/volt/priv.ex @@ -100,14 +100,23 @@ defmodule Volt.Priv do end defp render_source(source, relative, bindings, splices) do - if empty?(bindings) and empty?(splices) do - read!(source, relative) - else - source - |> template_ast!(relative) - |> OXC.bind(literal_bindings(bindings)) - |> splice_all(splices) - |> OXC.codegen!() + cond do + empty?(bindings) and empty?(splices) -> + read!(source, relative) + + empty?(bindings) -> + source + |> read!(relative) + |> OXC.parse!(relative, :native) + |> splice_all(splices) + |> OXC.codegen!() + + true -> + source + |> template_ast!(relative) + |> OXC.bind(literal_bindings(bindings)) + |> splice_all(splices) + |> OXC.codegen!() end end From 9a8f375ab3cc2bcbb75ad22034b9cbee8b6f9875 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 22:21:22 +0200 Subject: [PATCH 016/128] Update OXC to 0.17.7 --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 2073831..dc0a563 100644 --- a/mix.exs +++ b/mix.exs @@ -35,7 +35,7 @@ defmodule Volt.MixProject do [ {:reach, "~> 2.6.1", only: [:dev, :test], runtime: false}, {:glob_ex, "~> 0.1"}, - {:oxc, "~> 0.17.6"}, + {:oxc, "~> 0.17.7"}, {:vize, "~> 0.14.0"}, {:oxide_ex, "~> 0.2.1"}, {:quickbeam, "~> 0.10.20"}, diff --git a/mix.lock b/mix.lock index d041a6e..5c67e18 100644 --- a/mix.lock +++ b/mix.lock @@ -40,7 +40,7 @@ "npm": {:hex, :npm, "0.7.5", "d8680384b4116a29270cd1fb706da017d1be3870d7d2993de382140d7b98d378", [: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", "174f6b230669f08240a87bef76f3d9ed03e03c126fea6ea4d1a3011a1681a95f"}, "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, - "oxc": {:hex, :oxc, "0.17.6", "470b24662de156f749f48d16ae2b3cd2d8916e8fe10467e18267debe87d914d4", [: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", "b25421081f9b7ec1345210794b28e5c2cc05e37c83368c935e71c8100d150347"}, + "oxc": {:hex, :oxc, "0.17.7", "2964e56c3682fa16e68732e0ad8ccec92f667efc643b39147b5f017b9771e885", [: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", "246d3d45925a08bbea4681ffb38a1935df14d73ec1929669957822c77f447e2c"}, "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"}, "pegasus": {:hex, :pegasus, "0.2.6", "b4af6522326fbb2ffd1bb706e78ec05854fbcb4b03a7fe08f8db2e9de1d0be67", [:mix], [{:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0ac159f0ccab7967cf90208327cc8a35788874814c8d78e19d47104d3fc049b9"}, "pi_bridge": {:hex, :pi_bridge, "0.6.21", "f1a63da5f49fef4f6b35e98edd5400cbd64a49fb8bda84be41b7e91f3deb0b89", [:mix], [{:bandit, "~> 1.8", [hex: :bandit, repo: "hexpm", optional: false]}, {:dune, "~> 0.3", [hex: :dune, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_ast, "~> 0.12", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.1.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:quackdb, "~> 0.5.4", [hex: :quackdb, repo: "hexpm", optional: false]}, {:reach, "~> 2.6", [hex: :reach, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:req_llm, "~> 1.6", [hex: :req_llm, repo: "hexpm", optional: true]}], "hexpm", "b226fb60965a8e928186af3c3b96fe8f0fefd8c6cc7be679d6e169ba84105de9"}, From 7ffb8728ea5f0359b47da6c9c099fd8e478519a3 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 16 Jul 2026 22:25:01 +0200 Subject: [PATCH 017/128] Prepare v0.17.7 release --- CHANGELOG.md | 2 +- mix.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0429843..5237c6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.17.7 - 2026-07-16 ### Improved diff --git a/mix.exs b/mix.exs index dc0a563..197ab71 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.17.6" + @version "0.17.7" @source_url "https://github.com/elixir-volt/volt" def project do From 1e9651f3faf26864fbc37c8a69bda6af2e43cb2c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 12:15:38 +0200 Subject: [PATCH 018/128] Avoid ESTree serialization during production builds --- CHANGELOG.md | 6 ++++++ lib/volt/builder.ex | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5237c6a..b1f930f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Improved + +- Rewrite production import labels from compact OXC parser events, avoiding full ESTree serialization for every compiled module. + ## 0.17.7 - 2026-07-16 ### Improved diff --git a/lib/volt/builder.ex b/lib/volt/builder.ex index d8b2044..a3638f9 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -636,6 +636,41 @@ defmodule Volt.Builder do end defp rewrite_imports_to_labels(code, label_map, from_label, global_map) do + case compact_import_label_patches(code, label_map, from_label, global_map) do + {:ok, []} -> code + {:ok, patches} -> OXC.patch_string(code, patches) + :fallback -> rewrite_imports_to_labels_from_ast(code, label_map, from_label, global_map) + end + end + + defp compact_import_label_patches(code, label_map, from_label, global_map) do + with {:ok, imports} <- OXC.select(code, "module.js", :import_sources), + {:ok, []} <- OXC.select(code, "module.js", :require_calls), + false <- dynamic_css_import?(imports) do + patches = + Enum.reduce(imports, [], fn %{specifier: specifier, start: start, end: end_offset}, acc -> + case rewrite_specifier(specifier, label_map, from_label, global_map) do + {:rewrite, replacement} -> + [%{start: start, end: end_offset, change: Jason.encode!(replacement)} | acc] + + :keep -> + acc + end + end) + + {:ok, patches} + else + _unsupported_or_invalid -> :fallback + end + end + + defp dynamic_css_import?(imports) do + Enum.any?(imports, fn import -> + import.type == :dynamic and Path.extname(import.specifier) in @css_exts + end) + end + + defp rewrite_imports_to_labels_from_ast(code, label_map, from_label, global_map) do case OXC.parse(code, "module.js") do {:ok, ast} -> patches = collect_import_label_patches(ast, label_map, from_label, global_map) From 2fca865295e5e2766aeb4655569d7b1ff84b3b07 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 12:17:24 +0200 Subject: [PATCH 019/128] Prepare v0.17.8 release --- CHANGELOG.md | 2 +- mix.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1f930f..c98f6e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.17.8 - 2026-07-17 ### Improved diff --git a/mix.exs b/mix.exs index 197ab71..e8e333a 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.17.7" + @version "0.17.8" @source_url "https://github.com/elixir-volt/volt" def project do From 463fa4f492b0e2dc08e7a3d00fb72d1fdd3e4750 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 14:19:22 +0200 Subject: [PATCH 020/128] Add file-granular ExUnit tests --- CHANGELOG.md | 6 ++++ guides/features/testing.md | 8 +++++ lib/volt/test/config.ex | 3 ++ lib/volt/test/ex_unit.ex | 45 +++++++++++++++++++++++-- test/volt/test/config_test.exs | 3 ++ test/volt/test/ex_unit_test.exs | 58 +++++++++++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c98f6e4..b6eb756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Added + +- Allow `Volt.Test.ExUnit.install/1` to execute large suites once per file with `granularity: :file`. + ## 0.17.8 - 2026-07-17 ### Improved diff --git a/guides/features/testing.md b/guides/features/testing.md index 444162d..7775c54 100644 --- a/guides/features/testing.md +++ b/guides/features/testing.md @@ -66,6 +66,14 @@ Volt.Test.ExUnit.install( ) ``` +By default, each JavaScript `test(...)` is registered as an individual ExUnit test. Large suites can execute once per file instead: + +```elixir +Volt.Test.ExUnit.install(granularity: :file) +``` + +File granularity keeps file-level ExUnit reporting and JavaScript failure details while avoiding a separate bundle and runtime invocation for every test. It also tags browser-backed files with `browser_js: true`. + Profile-specific configuration follows the rest of Volt: ```elixir diff --git a/lib/volt/test/config.ex b/lib/volt/test/config.ex index 6c8fe23..85bedb6 100644 --- a/lib/volt/test/config.ex +++ b/lib/volt/test/config.ex @@ -22,6 +22,7 @@ defmodule Volt.Test.Config do exclude: ["vendor/**", "node_modules/**" | Volt.Paths.ignored_globs()], setup_files: [], browser: false, + granularity: :test, browsers: [:chromium], timeout: 30_000, bundle: [], @@ -34,6 +35,7 @@ defmodule Volt.Test.Config do exclude: [String.t()], setup_files: [String.t()], browser: boolean(), + granularity: :test | :file, browsers: [atom()], timeout: timeout(), bundle: keyword(), @@ -47,6 +49,7 @@ defmodule Volt.Test.Config do :exclude, :setup_files, :browser, + :granularity, :browsers, :timeout, :bundle, diff --git a/lib/volt/test/ex_unit.ex b/lib/volt/test/ex_unit.ex index 4c595ad..a6514ee 100644 --- a/lib/volt/test/ex_unit.ex +++ b/lib/volt/test/ex_unit.ex @@ -20,6 +20,11 @@ defmodule Volt.Test.ExUnit do Pass `browser: true` to execute tests in a real Playwright browser instead of QuickBEAM while keeping the same ExUnit registration model. + + By default, each JavaScript test becomes an ExUnit test. Pass + `granularity: :file` to register one ExUnit test per file and execute the + complete file in one runtime invocation. File granularity is useful for + large suites where repeatedly bundling and starting a runtime is too costly. """ alias Volt.Test.Config @@ -39,10 +44,16 @@ defmodule Volt.Test.ExUnit do module = module_name(file) unless Code.ensure_loaded?(module) do - tests = collect_tests!(file, profile, config) + quoted = + case config.granularity do + :test -> + bridge_module(module, file, profile, config, collect_tests!(file, profile, config)) + + :file -> + file_bridge_module(module, file, profile, config) + end - [{_compiled_module, _bytecode}] = - Code.compile_quoted(bridge_module(module, file, profile, config, tests), file) + [{_compiled_module, _bytecode}] = Code.compile_quoted(quoted, file) end module @@ -73,6 +84,34 @@ defmodule Volt.Test.ExUnit do end end + defp file_bridge_module(module, file, profile, config) do + empty_message = "Volt JS test file #{file} did not define any tests" + + quote do + defmodule unquote(module) do + use ExUnit.Case, async: false + + @moduletag js: true + @moduletag volt_file: unquote(file) + + if unquote(config.browser) do + @moduletag browser_js: true + end + + test unquote(Path.relative_to(file, config.root)) do + assert {:ok, result} = + unquote(runner(config)).run_file(unquote(file), + profile: unquote(profile), + config: unquote(Macro.escape(config)) + ) + + assert result.total > 0, unquote(empty_message) + Volt.Test.Assertions.assert_passed!(result) + end + end + end + end + defp test_definition(file, profile, config, %Volt.Test.Result.Metadata{id: test_id} = test) do name = test.full_name || test.name || inspect(test_id) function = Macro.unique_var(:name, __MODULE__) diff --git a/test/volt/test/config_test.exs b/test/volt/test/config_test.exs index 9266d4f..828c6f4 100644 --- a/test/volt/test/config_test.exs +++ b/test/volt/test/config_test.exs @@ -26,6 +26,7 @@ defmodule Volt.Test.ConfigTest do assert config.include == ["**/*.{test,spec}.{js,ts,jsx,tsx}"] assert "node_modules/**" in config.exclude assert config.browser == false + assert config.granularity == :test assert config.browsers == [:chromium] assert config.timeout == 30_000 assert config.bundle == [] @@ -36,6 +37,7 @@ defmodule Volt.Test.ConfigTest do root: "frontend", include: ["**/*.spec.ts"], browser: true, + granularity: :file, browsers: [:chromium, :firefox], timeout: 10_000, bundle: [plugins: [Volt.Plugin.React]] @@ -46,6 +48,7 @@ defmodule Volt.Test.ConfigTest do assert config.root == "frontend" assert config.include == ["**/*.spec.ts"] assert config.browser == true + assert config.granularity == :file assert config.browsers == [:chromium, :firefox] assert config.timeout == 10_000 assert config.bundle == [plugins: [Volt.Plugin.React]] diff --git a/test/volt/test/ex_unit_test.exs b/test/volt/test/ex_unit_test.exs index 99c84c5..23418c1 100644 --- a/test/volt/test/ex_unit_test.exs +++ b/test/volt/test/ex_unit_test.exs @@ -48,6 +48,45 @@ defmodule Volt.Test.ExUnitTest do end end + test "file granularity registers and executes one ExUnit test per file", %{tmp_dir: tmp_dir} do + file = + write!(tmp_dir, "file-granularity.test.ts", ~TS""" + import { test, expect } from 'volt:test' + + test('adds', () => expect(1 + 1).toBe(2)) + test('multiplies', () => expect(2 * 3).toBe(6)) + """) + + assert [module] = + Volt.Test.ExUnit.install( + root: tmp_dir, + include: ["file-granularity.test.ts"], + granularity: :file + ) + + assert [test] = module.__ex_unit__().tests + assert test_description(test) == "test file-granularity.test.ts" + assert test_tag(test, :js) == true + assert test_tag(test, :volt_file) == file + assert :ok = apply(module, test_name(test), [%{}]) + end + + test "file granularity tags browser modules", %{tmp_dir: tmp_dir} do + file = write!(tmp_dir, "browser-file.test.ts", "test('browser', () => {})\n") + + assert [module] = + Volt.Test.ExUnit.install( + root: tmp_dir, + include: ["browser-file.test.ts"], + browser: true, + granularity: :file + ) + + assert [test] = module.__ex_unit__().tests + assert test_tag(test, :browser_js) == true + assert test_tag(test, :volt_file) == file + end + test "install maps JS skip todo and tags to ExUnit tags", %{tmp_dir: tmp_dir} do file = write!(tmp_dir, "modifiers.test.ts", ~TS""" @@ -94,6 +133,25 @@ defmodule Volt.Test.ExUnitTest do end end + test "file granularity fails when a discovered JS test file defines no tests", %{ + tmp_dir: tmp_dir + } do + write!(tmp_dir, "empty-file.test.ts", "import { test } from 'volt:test'\n") + + assert [module] = + Volt.Test.ExUnit.install( + root: tmp_dir, + include: ["empty-file.test.ts"], + granularity: :file + ) + + [test] = module.__ex_unit__().tests + + assert_raise ExUnit.AssertionError, ~r/did not define any tests/, fn -> + apply(module, test_name(test), [%{}]) + end + end + defp test_description(%ExUnit.Test{name: name}), do: Atom.to_string(name) defp test_description(test), do: Map.fetch!(test, :description) defp test_name(test), do: Map.fetch!(test, :name) From cb6ffee5dfaa5bd82f198347c08e895f7d080a05 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 14:28:40 +0200 Subject: [PATCH 021/128] Prepare 0.17.9 release --- CHANGELOG.md | 2 +- mix.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6eb756..e8afd20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.17.9 - 2026-07-17 ### Added diff --git a/mix.exs b/mix.exs index e8e333a..5c4febe 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.17.8" + @version "0.17.9" @source_url "https://github.com/elixir-volt/volt" def project do From bf8abd508c301f89b66565f1a64235dba98b6dbe Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 14:51:24 +0200 Subject: [PATCH 022/128] Support package-owned npm lockfiles --- CHANGELOG.md | 10 ++ guides/features/plugins.md | 11 +- lib/volt/js/runtime/installer.ex | 143 ++++++++++++++++++++---- lib/volt/npm.ex | 8 ++ test/volt/js/runtime/installer_test.exs | 102 ++++++++++++++++- 5 files changed, 250 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8afd20..85271be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Added + +- Allow `Volt.NPM.install!/2` to consume package-owned npm_ex lockfiles for resolver-free deterministic installs. + +### Fixed + +- Preserve nested dependency versions when generating isolated runtime lockfiles. + ## 0.17.9 - 2026-07-17 ### Added diff --git a/guides/features/plugins.md b/guides/features/plugins.md index 5b8a1c0..199a93a 100644 --- a/guides/features/plugins.md +++ b/guides/features/plugins.md @@ -524,7 +524,11 @@ QuickBEAM can install those dependencies directly and scope them to the framewor source root: ```elixir -packages = Volt.NPM.install!(%{"framework-runtime" => "1.2.3"}) +packages = + Volt.NPM.install!( + %{"framework-runtime" => "1.2.3"}, + lockfile: Application.app_dir(:my_framework, "priv/npm.lock") + ) Volt.Builder.build( entry: "assets/app.ts", @@ -535,6 +539,11 @@ Volt.Builder.build( ) ``` +A package-owned npm_ex lockfile pins the complete transitive graph and lets fresh +cache installations skip registry resolution. Volt validates its version, security +policy, direct versions, integrity metadata, and required dependency closure before +linking packages. + An import originating below the scoped source root checks the framework package directory first. Imports from application modules continue to resolve from the application's `node_modules`, so package names and versions cannot shadow one another. diff --git a/lib/volt/js/runtime/installer.ex b/lib/volt/js/runtime/installer.ex index 21f5413..31c5093 100644 --- a/lib/volt/js/runtime/installer.ex +++ b/lib/volt/js/runtime/installer.ex @@ -16,12 +16,13 @@ defmodule Volt.JS.Runtime.Installer do def install!(packages, opts \\ []) when is_map(packages) do Application.ensure_all_started(:req) - id = install_id(packages) + source_lock = read_source_lock!(packages, opts[:lockfile]) + id = install_id(packages, source_lock) install_dir = Keyword.get(opts, :install_dir, default_install_dir(id)) node_modules = Path.join(install_dir, "node_modules") lockfile_path = Path.join(install_dir, "npm.lock") metadata_path = Path.join(install_dir, "volt-runtime.json") - signature = install_signature(packages) + signature = install_signature(packages, source_lock) with_lock(install_dir, fn -> if Keyword.get(opts, :force, false) or metadata_mismatch?(metadata_path, signature) do @@ -30,7 +31,7 @@ defmodule Volt.JS.Runtime.Installer do unless install_intact?(lockfile_path, node_modules, metadata_path, signature) do File.mkdir_p!(install_dir) - resolve_and_link!(packages, node_modules, lockfile_path) + install_packages!(packages, source_lock, node_modules, lockfile_path) write_metadata!(metadata_path, signature, packages) end end) @@ -38,15 +39,30 @@ defmodule Volt.JS.Runtime.Installer do %{install_dir: install_dir, node_modules: node_modules} end + defp install_packages!(packages, nil, node_modules, lockfile_path) do + resolve_and_link!(packages, node_modules, lockfile_path) + end + + defp install_packages!(_packages, source_lock, node_modules, lockfile_path) do + File.cp!(source_lock.path, lockfile_path) + link!(source_lock.packages, node_modules) + warn_ignored_install_scripts(source_lock.packages) + end + defp resolve_and_link!(packages, node_modules, lockfile_path) do NPM.Resolver.clear_cache() case NPM.Resolver.resolve(packages) do {:ok, resolved} -> - {_nested, flat} = Map.pop(resolved, :nested, %{}) - lockfile = build_lockfile(flat) + {nested, flat} = Map.pop(resolved, :nested, %{}) + + lockfile = + flat + |> NPM.Install.LockfileBuilder.build(&ignore_age_warning/3) + |> NPM.Install.NestedLockfile.add(nested, &ignore_age_warning/3) + NPM.Lockfile.write(lockfile, lockfile_path) - NPM.Install.Linker.link(lockfile, node_modules) + link!(lockfile, node_modules) warn_ignored_install_scripts(lockfile) {:error, message} -> @@ -54,23 +70,15 @@ defmodule Volt.JS.Runtime.Installer do end end - defp build_lockfile(resolved) do - for {name, version} <- resolved, into: %{} do - {:ok, packument} = NPM.Registry.get_packument(name) - info = Map.fetch!(packument.versions, version) - - {name, - %{ - version: version, - integrity: info.dist.integrity, - tarball: info.dist.tarball, - dependencies: info.dependencies, - optional_dependencies: info.optional_dependencies, - has_install_script: info.has_install_script - }} + defp link!(lockfile, node_modules) do + case NPM.Install.Linker.link(lockfile, node_modules) do + :ok -> :ok + {:error, reason} -> raise "NPM package installation failed: #{inspect(reason)}" end end + defp ignore_age_warning(_name, _version, _info), do: :ok + defp warn_ignored_install_scripts(lockfile) do packages = lockfile @@ -121,11 +129,102 @@ defmodule Volt.JS.Runtime.Installer do File.write!(metadata_path, Jason.encode!(metadata)) end - defp install_id(packages), do: install_signature(packages) + defp read_source_lock!(_packages, nil), do: nil + + defp read_source_lock!(packages, path) when is_binary(path) do + path = Path.expand(path) + contents = File.read!(path) + + unless NPM.Lockfile.version(path) == 1 do + raise ArgumentError, "expected npm lockfile version 1, got: #{inspect(path)}" + end + + {:ok, policy} = NPM.Lockfile.read_policy(path) + + unless NPM.Lockfile.policy_matches?(policy) do + raise ArgumentError, + "npm lockfile security policy does not match current configuration: #{path}" + end + + {:ok, lockfile} = NPM.Lockfile.read(path) + validate_source_lock!(packages, lockfile, path) + validate_lock_entries!(lockfile, lockfile, path) + + %{ + path: path, + packages: lockfile, + digest: :crypto.hash(:sha256, contents) + } + end + + defp read_source_lock!(_packages, path) do + raise ArgumentError, "expected :lockfile to be a path, got: #{inspect(path)}" + end + + defp validate_source_lock!(packages, lockfile, path) do + Enum.each(packages, fn {name, version} -> + case lockfile do + %{^name => %{version: ^version}} -> + :ok + + %{^name => %{version: locked_version}} -> + raise ArgumentError, + "npm lockfile version mismatch for #{name}: expected #{version}, got #{locked_version} in #{path}" - defp install_signature(packages) do + _ -> + raise ArgumentError, "npm lockfile is missing direct package #{name}: #{path}" + end + end) + end + + defp validate_lock_entries!(entries, root_lockfile, path) do + Enum.each(entries, fn {name, entry} -> + if entry.integrity == "" or entry.tarball == "" do + raise ArgumentError, "npm lockfile has incomplete package metadata for #{name}: #{path}" + end + + NPM.Security.RegistryPolicy.validate_url!(entry.tarball) + + Enum.each(entry.dependencies, fn {dependency, requirement} -> + nested_entry = Map.get(entry.nested_dependencies, dependency) + root_entry = Map.get(root_lockfile, dependency) + + unless locked_requirement?(nested_entry, requirement) or + locked_requirement?(root_entry, requirement) do + raise ArgumentError, + "npm lockfile does not satisfy #{name} dependency #{dependency}@#{requirement}: #{path}" + end + end) + + validate_lock_entries!(entry.nested_dependencies, root_lockfile, path) + end) + end + + defp locked_requirement?(nil, _requirement), do: false + + defp locked_requirement?(entry, requirement) do + NPMSemver.matches?(entry.version, requirement) + rescue + ArgumentError -> false + end + + defp install_id(packages, source_lock), do: install_signature(packages, source_lock) + + defp install_signature(packages, nil) do + packages + |> Enum.sort() + |> hash_install_signature() + end + + defp install_signature(packages, source_lock) do packages |> Enum.sort() + |> then(&{&1, source_lock.digest}) + |> hash_install_signature() + end + + defp hash_install_signature(value) do + value |> :erlang.term_to_binary() |> :erlang.md5() |> Base.encode16(case: :lower) diff --git a/lib/volt/npm.ex b/lib/volt/npm.ex index 6a65abf..753efc2 100644 --- a/lib/volt/npm.ex +++ b/lib/volt/npm.ex @@ -6,6 +6,14 @@ defmodule Volt.NPM do without adding those packages to an application's `node_modules` directory. The returned directory is stable for the normalized package set and can be passed to `Volt.Builder` as a package directory or scoped package root. + + Pass a package-owned npm_ex lockfile to make fresh installs consume its exact + transitive graph without contacting the registry resolver: + + Volt.NPM.install!(packages, lockfile: Volt.Priv.path(:my_app, "npm.lock")) + + The lockfile must use npm_ex lockfile version 1, match the current security + policy, and contain every direct package at its declared exact version. """ @type install_result :: %{install_dir: String.t(), node_modules: String.t()} diff --git a/test/volt/js/runtime/installer_test.exs b/test/volt/js/runtime/installer_test.exs index b237826..e29e89b 100644 --- a/test/volt/js/runtime/installer_test.exs +++ b/test/volt/js/runtime/installer_test.exs @@ -10,7 +10,19 @@ defmodule Volt.JS.Runtime.InstallerTest do "volt-runtime-installer-test-#{System.unique_integer([:positive])}" ) - on_exit(fn -> File.rm_rf!(tmp_dir) end) + File.mkdir_p!(tmp_dir) + original_cache_dir = Application.get_env(:npm, :cache_dir) + Application.put_env(:npm, :cache_dir, Path.join(tmp_dir, "npm-cache")) + + on_exit(fn -> + File.rm_rf!(tmp_dir) + + if original_cache_dir do + Application.put_env(:npm, :cache_dir, original_cache_dir) + else + Application.delete_env(:npm, :cache_dir) + end + end) %{tmp_dir: tmp_dir} end @@ -37,6 +49,94 @@ defmodule Volt.JS.Runtime.InstallerTest do assert second["packages"] == %{"left-pad" => "1.3.0"} end + test "installs a package-owned lockfile without resolving", %{tmp_dir: tmp_dir} do + package = "volt-locked-only" + version = "1.0.0" + cache_package!(package, version) + source_lock = write_lock!(tmp_dir, %{package => lock_entry(version)}) + install_dir = Path.join(tmp_dir, "install") + + install = + Installer.install!(%{package => version}, + install_dir: install_dir, + lockfile: source_lock + ) + + assert File.read!(Path.join(install_dir, "npm.lock")) == File.read!(source_lock) + assert File.read!(Path.join([install.node_modules, package, "package.json"])) =~ package + end + + test "includes package-owned lockfile contents in the install signature", %{tmp_dir: tmp_dir} do + package = "volt-locked-signature" + version = "1.0.0" + cache_package!(package, version) + source_lock = write_lock!(tmp_dir, %{package => lock_entry(version)}) + install_dir = Path.join(tmp_dir, "install") + + Installer.install!(%{package => version}, install_dir: install_dir, lockfile: source_lock) + first = read_metadata(install_dir) + + source_lock = + write_lock!(tmp_dir, %{package => %{lock_entry(version) | integrity: "sha512-changed"}}) + + Installer.install!(%{package => version}, install_dir: install_dir, lockfile: source_lock) + second = read_metadata(install_dir) + + assert first["signature"] != second["signature"] + assert File.read!(Path.join(install_dir, "npm.lock")) == File.read!(source_lock) + end + + test "rejects a package-owned lockfile with a mismatched direct version", %{tmp_dir: tmp_dir} do + source_lock = write_lock!(tmp_dir, %{"locked-package" => lock_entry("1.0.0")}) + + assert_raise ArgumentError, ~r/version mismatch.*expected 2.0.0, got 1.0.0/, fn -> + Installer.install!(%{"locked-package" => "2.0.0"}, + install_dir: Path.join(tmp_dir, "install"), + lockfile: source_lock + ) + end + end + + test "rejects an incomplete package-owned dependency graph", %{tmp_dir: tmp_dir} do + entry = %{lock_entry("1.0.0") | dependencies: %{"missing-transitive" => "^2.0.0"}} + source_lock = write_lock!(tmp_dir, %{"locked-package" => entry}) + + assert_raise ArgumentError, ~r/does not satisfy.*missing-transitive@\^2.0.0/, fn -> + Installer.install!(%{"locked-package" => "1.0.0"}, + install_dir: Path.join(tmp_dir, "install"), + lockfile: source_lock + ) + end + end + + defp cache_package!(name, version) do + package_dir = NPM.Cache.package_dir(name, version) + File.mkdir_p!(package_dir) + + File.write!( + Path.join(package_dir, "package.json"), + Jason.encode!(%{name: name, version: version}) + ) + end + + defp write_lock!(tmp_dir, lockfile) do + path = Path.join(tmp_dir, "source-npm.lock") + :ok = NPM.Lockfile.write(lockfile, path) + path + end + + defp lock_entry(version) do + %{ + version: version, + integrity: "sha512-test", + tarball: "https://registry.npmjs.org/unused.tgz", + dependencies: %{}, + optional_dependencies: %{}, + has_install_script: false, + nested_dependencies: %{} + } + end + defp read_metadata(install_dir) do install_dir |> Path.join("volt-runtime.json") From d8a918f6702f6322a1d9ecf57189ee0e0dc22cdb Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 15:14:20 +0200 Subject: [PATCH 023/128] Prepare 0.17.10 release --- CHANGELOG.md | 2 +- mix.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85271be..49f9f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.17.10 - 2026-07-17 ### Added diff --git a/mix.exs b/mix.exs index 5c4febe..dace3ee 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.17.9" + @version "0.17.10" @source_url "https://github.com/elixir-volt/volt" def project do From 152d7b15f9db711f8e8580bde6a60d901e6ad7ab Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 18:19:39 +0200 Subject: [PATCH 024/128] Document Phoenix digest layering --- examples/react/config/config.exs | 1 - .../components/layouts/root.html.heex | 2 +- examples/react/mix.exs | 4 ++-- examples/solid/config/config.exs | 1 - .../components/layouts/root.html.heex | 2 +- examples/solid/mix.exs | 4 ++-- examples/svelte/config/config.exs | 1 - .../components/layouts/root.html.heex | 2 +- examples/svelte/mix.exs | 4 ++-- examples/vanilla/config/config.exs | 1 - .../components/layouts/root.html.heex | 2 +- examples/vanilla/mix.exs | 4 ++-- examples/vue/config/config.exs | 1 - .../components/layouts/root.html.heex | 2 +- examples/vue/mix.exs | 4 ++-- guides/cheatsheets/cli.cheatmd | 2 +- guides/deployment/production-builds.md | 12 +++++++++++- guides/features/features.md | 2 +- 18 files changed, 28 insertions(+), 23 deletions(-) diff --git a/examples/react/config/config.exs b/examples/react/config/config.exs index 7284372..fbb5c48 100644 --- a/examples/react/config/config.exs +++ b/examples/react/config/config.exs @@ -23,7 +23,6 @@ config :volt, sources: ["**/*.{js,ts,jsx,tsx}"], target: :es2020, minify: false, - hash: false, resolve_dirs: ["node_modules", "deps"], tailwind: [ css: "assets/css/app.css", diff --git a/examples/react/lib/react_example_web/components/layouts/root.html.heex b/examples/react/lib/react_example_web/components/layouts/root.html.heex index bc3c5f4..e059290 100644 --- a/examples/react/lib/react_example_web/components/layouts/root.html.heex +++ b/examples/react/lib/react_example_web/components/layouts/root.html.heex @@ -5,7 +5,7 @@ <.live_title default="Volt React Example">Volt React Example - + diff --git a/examples/react/mix.exs b/examples/react/mix.exs index 28d06b2..fdcabe5 100644 --- a/examples/react/mix.exs +++ b/examples/react/mix.exs @@ -43,8 +43,8 @@ defmodule ReactExample.MixProject do defp aliases do [ setup: ["deps.get", "npm.install", "assets.build"], - "assets.build": ["volt.build --tailwind --no-hash --no-minify"], - "assets.deploy": ["volt.build --tailwind --no-hash", "phx.digest"], + "assets.build": ["volt.build --tailwind --no-minify"], + "assets.deploy": ["volt.build --tailwind", "phx.digest"], "assets.check": ["volt.js.check"], "assets.check.type_aware": ["volt.js.check --type-aware --type-check"], precommit: ["compile --warnings-as-errors", "format", "test"] diff --git a/examples/solid/config/config.exs b/examples/solid/config/config.exs index 6ef3959..d40c8a5 100644 --- a/examples/solid/config/config.exs +++ b/examples/solid/config/config.exs @@ -23,7 +23,6 @@ config :volt, sources: ["**/*.{js,ts,jsx,tsx}"], target: :es2020, minify: false, - hash: false, resolve_dirs: ["node_modules", "deps"], plugins: [Volt.Plugin.Solid], tailwind: [ diff --git a/examples/solid/lib/solid_example_web/components/layouts/root.html.heex b/examples/solid/lib/solid_example_web/components/layouts/root.html.heex index ec520e6..f08df0b 100644 --- a/examples/solid/lib/solid_example_web/components/layouts/root.html.heex +++ b/examples/solid/lib/solid_example_web/components/layouts/root.html.heex @@ -5,7 +5,7 @@ <.live_title default="Volt Solid Example">Volt Solid Example - + diff --git a/examples/solid/mix.exs b/examples/solid/mix.exs index 9c41f61..162b121 100644 --- a/examples/solid/mix.exs +++ b/examples/solid/mix.exs @@ -43,8 +43,8 @@ defmodule SolidExample.MixProject do defp aliases do [ setup: ["deps.get", "npm.install", "assets.build"], - "assets.build": ["volt.build --tailwind --no-hash --no-minify"], - "assets.deploy": ["volt.build --tailwind --no-hash", "phx.digest"], + "assets.build": ["volt.build --tailwind --no-minify"], + "assets.deploy": ["volt.build --tailwind", "phx.digest"], "assets.check": ["volt.js.check"], "assets.check.type_aware": ["volt.js.check --type-aware --type-check"], precommit: ["compile --warnings-as-errors", "format", "test"] diff --git a/examples/svelte/config/config.exs b/examples/svelte/config/config.exs index 8f9b141..8cc9c41 100644 --- a/examples/svelte/config/config.exs +++ b/examples/svelte/config/config.exs @@ -23,7 +23,6 @@ config :volt, sources: ["**/*.{js,ts,jsx,tsx,svelte}"], target: :es2022, minify: false, - hash: false, resolve_dirs: ["node_modules", "deps"], tailwind: [ css: "assets/css/app.css", diff --git a/examples/svelte/lib/svelte_example_web/components/layouts/root.html.heex b/examples/svelte/lib/svelte_example_web/components/layouts/root.html.heex index b31db71..308d4b4 100644 --- a/examples/svelte/lib/svelte_example_web/components/layouts/root.html.heex +++ b/examples/svelte/lib/svelte_example_web/components/layouts/root.html.heex @@ -5,7 +5,7 @@ <.live_title default="Volt Svelte Example">Volt Svelte Example - + diff --git a/examples/svelte/mix.exs b/examples/svelte/mix.exs index b401cdc..70583ea 100644 --- a/examples/svelte/mix.exs +++ b/examples/svelte/mix.exs @@ -43,8 +43,8 @@ defmodule SvelteExample.MixProject do defp aliases do [ setup: ["deps.get", "npm.install", "assets.build"], - "assets.build": ["volt.build --tailwind --no-hash --no-minify"], - "assets.deploy": ["volt.build --tailwind --no-hash", "phx.digest"], + "assets.build": ["volt.build --tailwind --no-minify"], + "assets.deploy": ["volt.build --tailwind", "phx.digest"], "assets.check": ["volt.js.check"], "assets.check.type_aware": ["volt.js.check --type-aware --type-check"], precommit: ["compile --warnings-as-errors", "format", "test"] diff --git a/examples/vanilla/config/config.exs b/examples/vanilla/config/config.exs index 24684b4..d4840f1 100644 --- a/examples/vanilla/config/config.exs +++ b/examples/vanilla/config/config.exs @@ -23,7 +23,6 @@ config :volt, sources: ["**/*.{js,ts}"], target: :es2020, minify: false, - hash: false, resolve_dirs: ["node_modules", "deps", Mix.Project.build_path()], tailwind: [ css: "assets/css/app.css", diff --git a/examples/vanilla/lib/vanilla_example_web/components/layouts/root.html.heex b/examples/vanilla/lib/vanilla_example_web/components/layouts/root.html.heex index a61e1a3..67cdddc 100644 --- a/examples/vanilla/lib/vanilla_example_web/components/layouts/root.html.heex +++ b/examples/vanilla/lib/vanilla_example_web/components/layouts/root.html.heex @@ -5,7 +5,7 @@ <.live_title default="Volt Vanilla Example">Volt Vanilla Example - + diff --git a/examples/vanilla/mix.exs b/examples/vanilla/mix.exs index 36e73db..5548284 100644 --- a/examples/vanilla/mix.exs +++ b/examples/vanilla/mix.exs @@ -43,8 +43,8 @@ defmodule VanillaExample.MixProject do defp aliases do [ setup: ["deps.get", "npm.install", "assets.build"], - "assets.build": ["volt.build --tailwind --no-hash --no-minify"], - "assets.deploy": ["volt.build --tailwind --no-hash", "phx.digest"], + "assets.build": ["volt.build --tailwind --no-minify"], + "assets.deploy": ["volt.build --tailwind", "phx.digest"], "assets.check": ["volt.js.check"], "assets.check.type_aware": ["volt.js.check --type-aware --type-check"], precommit: ["compile --warnings-as-errors", "format", "test"] diff --git a/examples/vue/config/config.exs b/examples/vue/config/config.exs index 87bf3a2..71b1590 100644 --- a/examples/vue/config/config.exs +++ b/examples/vue/config/config.exs @@ -23,7 +23,6 @@ config :volt, sources: ["**/*.{js,ts,jsx,tsx,vue}"], target: :es2020, minify: false, - hash: false, resolve_dirs: ["node_modules", "deps"], tailwind: [ css: "assets/css/app.css", diff --git a/examples/vue/lib/vue_example_web/components/layouts/root.html.heex b/examples/vue/lib/vue_example_web/components/layouts/root.html.heex index 5b377a3..714dfa7 100644 --- a/examples/vue/lib/vue_example_web/components/layouts/root.html.heex +++ b/examples/vue/lib/vue_example_web/components/layouts/root.html.heex @@ -5,7 +5,7 @@ <.live_title default="Volt Vue Example">Volt Vue Example - + diff --git a/examples/vue/mix.exs b/examples/vue/mix.exs index 8d84584..226ef67 100644 --- a/examples/vue/mix.exs +++ b/examples/vue/mix.exs @@ -43,8 +43,8 @@ defmodule VueExample.MixProject do defp aliases do [ setup: ["deps.get", "npm.install", "assets.build"], - "assets.build": ["volt.build --tailwind --no-hash --no-minify"], - "assets.deploy": ["volt.build --tailwind --no-hash", "phx.digest"], + "assets.build": ["volt.build --tailwind --no-minify"], + "assets.deploy": ["volt.build --tailwind", "phx.digest"], "assets.check": ["volt.js.check"], "assets.check.type_aware": ["volt.js.check --type-aware --type-check"], precommit: ["compile --warnings-as-errors", "format", "test"] diff --git a/guides/cheatsheets/cli.cheatmd b/guides/cheatsheets/cli.cheatmd index 5510358..cdd8e9e 100644 --- a/guides/cheatsheets/cli.cheatmd +++ b/guides/cheatsheets/cli.cheatmd @@ -15,7 +15,7 @@ Set up Volt in a Phoenix project. ```bash mix volt.build -mix volt.build --tailwind --no-hash +mix volt.build --tailwind ``` Build production assets. diff --git a/guides/deployment/production-builds.md b/guides/deployment/production-builds.md index df1cd6c..4c37a6d 100644 --- a/guides/deployment/production-builds.md +++ b/guides/deployment/production-builds.md @@ -107,6 +107,16 @@ For code-split builds, the production manifest records static imports, dynamic i 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. +## Volt hashes and Phoenix digests + +Volt and Phoenix have separate responsibilities in the default deployment pipeline: + +1. Volt gives entries, chunks, CSS, and graph assets content-hashed filenames and records their relationships in its `manifest.json`. +2. `phx.digest` generates Phoenix's cache manifest, compressed copies, and final static filenames. +3. In production, `Volt.static_path/2` resolves the logical path through Volt's manifest and then passes the result through the Phoenix endpoint's `static_path/1`. With Phoenix's default configuration, the final entry URL therefore includes both hashes and `?vsn=d`. + +Keep Volt hashing enabled for code-split builds. Internal static and dynamic imports use Volt's filenames directly, so those hashes provide cache busting independently of Phoenix's entry URL. `--no-hash` is intended for stable-filename builds that deliberately rely on Phoenix as their only digest layer. + ## Deploy Alias The installer generates an `assets.deploy` alias: @@ -115,4 +125,4 @@ The installer generates an `assets.deploy` alias: "assets.deploy": ["volt.build --tailwind", "phx.digest"] ``` -This builds assets with content hashes, then generates the Phoenix digest manifest for CDN deployment. +This builds assets with Volt content hashes, then generates the Phoenix digest manifest and compressed files for deployment. Volt is an explicit asset task rather than a Mix compiler, so a later `mix release` does not rebuild or remove this finalized output. diff --git a/guides/features/features.md b/guides/features/features.md index 059e7d7..ede89ea 100644 --- a/guides/features/features.md +++ b/guides/features/features.md @@ -128,7 +128,7 @@ Use `Volt.static_path/2` in your root layout to link to Volt-managed scripts and ``` -In development, JavaScript entries return the source path served by the dev server (e.g. `/assets/js/app.ts`) and other paths are returned unchanged. In production, Volt reads `manifest.json` and returns the content-hashed path for scripts, stylesheets, and emitted image/font assets (e.g. `/assets/js/app-5e6f7a8b.js`, `/assets/css/app-2aa55585.css`, or `/assets/js/logo-a1b2c3d4.svg`). +In development, JavaScript entries return the source path served by the dev server (e.g. `/assets/js/app.ts`) and other paths are returned unchanged. In production, Volt reads `manifest.json` to find the content-hashed script, stylesheet, or emitted asset and then passes that path through the Phoenix endpoint's `static_path/1`. After `phx.digest`, the returned URL uses Phoenix's final digest and `?vsn=d`; without a Phoenix cache manifest it remains the Volt-hashed path. See [Production Builds](../deployment/production-builds.md#volt-hashes-and-phoenix-digests). ## Multi-Entry Builds From 08b6e196ff49b083eca34490290eb33a6daf0c06 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 18 Jul 2026 01:12:15 +0200 Subject: [PATCH 025/128] Support Windows path semantics --- CHANGELOG.md | 8 +++ lib/volt/builder.ex | 14 +++-- lib/volt/builder/collector.ex | 19 +++--- lib/volt/builder/naming.ex | 34 ++++++++++ lib/volt/builder/output.ex | 13 ++-- lib/volt/builder/resolver.ex | 2 +- lib/volt/js/transforms/glob_imports.ex | 1 + lib/volt/tailwind/resolver.ex | 8 ++- lib/volt/test/case.ex | 6 ++ lib/volt/test/discovery.ex | 2 +- test/mix/tasks/volt.build_test.exs | 2 +- test/mix/tasks/volt.js.check_test.exs | 58 +++++++++-------- test/support/volt/builder_case.exs | 2 +- test/support/volt/dev_server_case.exs | 2 +- test/volt/assets_test.exs | 2 +- test/volt/builder/bundle_test.exs | 5 +- test/volt/builder/naming_test.exs | 14 +++++ test/volt/builder/output/css_test.exs | 2 +- .../plugins_and_virtual_modules_test.exs | 4 +- test/volt/chunk_graph_test.exs | 63 +++++++++---------- test/volt/css/asset_url_rewriter_test.exs | 2 +- test/volt/css/dependencies_test.exs | 2 +- test/volt/dev_test.exs | 2 +- test/volt/hmr/boundary_test.exs | 22 +++---- test/volt/hmr/glob_graph_test.exs | 6 +- test/volt/hmr/style_dependencies_test.exs | 2 +- test/volt/js/helpers_test.exs | 5 +- test/volt/js/resolver_test.exs | 5 +- test/volt/plugin_runner_test.exs | 2 +- test/volt/priv_test.exs | 3 +- test/volt/test/discovery_test.exs | 10 +-- test/volt/test/ex_unit_test.exs | 2 +- test/volt/test/runner_test.exs | 2 +- test/volt/watcher_test.exs | 4 +- 34 files changed, 211 insertions(+), 119 deletions(-) create mode 100644 lib/volt/builder/naming.ex create mode 100644 test/volt/builder/naming_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 49f9f1a..bf36a55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Fixed + +- Generate filesystem-safe entry and module labels for virtual modules on Windows. +- Use platform-aware absolute paths for builder, test discovery, glob transforms, and Tailwind resolution. +- Make type-aware lint test executables portable across Unix and Windows. + ## 0.17.10 - 2026-07-17 ### Added diff --git a/lib/volt/builder.ex b/lib/volt/builder.ex index a3638f9..2afbdab 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -11,7 +11,7 @@ defmodule Volt.Builder do manifest. """ - alias Volt.Builder.{Bundle, Collector, Output} + alias Volt.Builder.{Bundle, Collector, Naming, Output} alias Volt.Paths @css_exts Volt.JS.Extensions.css() @@ -789,11 +789,17 @@ defmodule Volt.Builder do if Volt.HTMLEntry.html?(entry) do {:ok, %{scripts: scripts, styles: styles}} = Volt.HTMLEntry.extract(entry) - Enum.map(scripts, &{&1, :script, Path.basename(&1) |> Path.rootname()}) ++ - Enum.map(styles, &{&1, :style, Path.basename(&1) |> Path.rootname()}) + Enum.map( + scripts, + &{&1, :script, &1 |> Path.basename() |> Path.rootname() |> Naming.file_path()} + ) ++ + Enum.map( + styles, + &{&1, :style, &1 |> Path.basename() |> Path.rootname() |> Naming.file_path()} + ) else type = if Path.extname(entry) in @css_exts, do: :style, else: :script - entry_name = override_name || entry |> Path.basename() |> Path.rootname() + entry_name = Naming.file_path(override_name || entry |> Path.basename() |> Path.rootname()) [{entry, type, entry_name}] end end diff --git a/lib/volt/builder/collector.ex b/lib/volt/builder/collector.ex index c2c99ce..197dee0 100644 --- a/lib/volt/builder/collector.ex +++ b/lib/volt/builder/collector.ex @@ -1,7 +1,7 @@ defmodule Volt.Builder.Collector do @moduledoc "Walks the dependency graph from entry files, collecting modules and workers." - alias Volt.Builder.Resolver + alias Volt.Builder.{Naming, Resolver} @doc """ Walk the dependency graph from an entry file. @@ -12,7 +12,7 @@ defmodule Volt.Builder.Collector do - `workers` is `%{importer_path => %{specifier => worker_abs_path}}` """ def collect(entry_path, ctx) do - label = Path.basename(entry_path) + label = entry_path |> Path.basename() |> Naming.file_path() state = %Volt.Builder.Collector.State{ ctx: ctx, @@ -336,12 +336,15 @@ defmodule Volt.Builder.Collector do end |> with_query_suffix(query) - cond do - Path.extname(label) == ".json" -> Path.rootname(label) <> ".json.js" - Path.extname(label) in Volt.JS.Extensions.css() -> label <> ".js" - query != "" -> label <> ".js" - true -> label - end + label = + cond do + Path.extname(label) == ".json" -> Path.rootname(label) <> ".json.js" + Path.extname(label) in Volt.JS.Extensions.css() -> label <> ".js" + query != "" -> label <> ".js" + true -> label + end + + Naming.file_path(label) end defp with_query_suffix(label, ""), do: label diff --git a/lib/volt/builder/naming.ex b/lib/volt/builder/naming.ex new file mode 100644 index 0000000..88505cf --- /dev/null +++ b/lib/volt/builder/naming.ex @@ -0,0 +1,34 @@ +defmodule Volt.Builder.Naming do + @moduledoc false + + @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 file_path(String.t()) :: String.t() + def file_path(path) do + path + |> String.replace("\\", "/") + |> String.split("/", trim: false) + |> Enum.map_join("/", &file_segment/1) + end + + defp file_segment(segment) do + segment = + segment + |> String.replace(~r/[<>:"|?*\x00-\x1F]/u, "_") + |> String.replace(~r/[ .]+$/u, "") + + cond do + segment == "" -> "_" + reserved?(segment) -> "_" <> segment + true -> segment + end + end + + defp reserved?(segment) do + segment + |> String.split(".", parts: 2) + |> hd() + |> String.upcase() + |> then(&(&1 in @windows_reserved)) + end +end diff --git a/lib/volt/builder/output.ex b/lib/volt/builder/output.ex index 05d5a92..5b5f659 100644 --- a/lib/volt/builder/output.ex +++ b/lib/volt/builder/output.ex @@ -1,7 +1,7 @@ defmodule Volt.Builder.Output do @moduledoc "Builds final production output files from compiled module graphs." - alias Volt.Builder.{Writer, Rewriter} + 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 @@ -13,7 +13,9 @@ defmodule Volt.Builder.Output do } = build_ctx js_files = Rewriter.rewrite_external_imports(js_files, ctx) - bundle_opts = Keyword.put(bundle_opts, :entry, Path.basename(entry)) + entry_label = entry |> Path.basename() |> Naming.file_path() + entry_name = Path.rootname(entry_label) + bundle_opts = Keyword.put(bundle_opts, :entry, entry_label) case bundle_js_files(js_files, bundle_opts) do {:ok, bundle_result} -> @@ -24,12 +26,12 @@ defmodule Volt.Builder.Output do Rewriter.rewrite_worker_urls( js_code, Rewriter.all_worker_map(ctx), - Path.rootname(Path.basename(entry)) + entry_name ) js_code = Volt.PluginRunner.render_chunk(ctx.plugins, js_code, %{ - name: Path.rootname(Path.basename(entry)), + name: entry_name, type: :entry }) @@ -66,7 +68,8 @@ defmodule Volt.Builder.Output do File.mkdir_p!(outdir) js_files = Rewriter.rewrite_external_imports(js_files, ctx) - bundle_opts = Keyword.put(bundle_opts, :entry, Path.basename(entry)) + entry_label = entry |> Path.basename() |> Naming.file_path() + bundle_opts = Keyword.put(bundle_opts, :entry, entry_label) case bundle_js_files(js_files, bundle_opts) do {:ok, bundle_result} -> diff --git a/lib/volt/builder/resolver.ex b/lib/volt/builder/resolver.ex index d0504fd..ea11856 100644 --- a/lib/volt/builder/resolver.ex +++ b/lib/volt/builder/resolver.ex @@ -16,7 +16,7 @@ defmodule Volt.Builder.Resolver do end end - def absolute?(specifier), do: String.starts_with?(specifier, "/") + def absolute?(specifier), do: Path.type(specifier) == :absolute defp do_resolve(specifier, importer, ctx, query) do case Volt.PluginRunner.resolve(ctx.plugins, specifier, importer) do diff --git a/lib/volt/js/transforms/glob_imports.ex b/lib/volt/js/transforms/glob_imports.ex index 6a5be1a..17da1ad 100644 --- a/lib/volt/js/transforms/glob_imports.ex +++ b/lib/volt/js/transforms/glob_imports.ex @@ -224,6 +224,7 @@ defmodule Volt.JS.Transforms.GlobImports do end defp wildcard(pattern, base_dir, base) do + base_dir = Path.expand(base_dir) key_base_dir = key_base_dir(base, base_dir) Path.join(base_dir, pattern) diff --git a/lib/volt/tailwind/resolver.ex b/lib/volt/tailwind/resolver.ex index 57f5292..4c12414 100644 --- a/lib/volt/tailwind/resolver.ex +++ b/lib/volt/tailwind/resolver.ex @@ -46,7 +46,9 @@ defmodule Volt.Tailwind.Resolver do do: NPM.Resolution.PackageResolver.node_builtin?(specifier) def relative_specifier?(specifier), do: NPM.Resolution.PackageResolver.relative?(specifier) - def absolute_specifier?(specifier), do: String.starts_with?(specifier, "/") + + def absolute_specifier?(specifier), + do: Path.type(specifier) in [:absolute, :volumerelative] defp resolve_bare_path!(id, base, extensions, index_files, kind, runtime_node_modules) do {package_name, subpath} = NPM.Resolution.PackageResolver.split_specifier(id) @@ -75,7 +77,7 @@ defmodule Volt.Tailwind.Resolver do case resolved do {:ok, path} -> - path + Path.expand(path) nil -> raise "Could not resolve #{kind} #{inspect(id)} from #{inspect(base)}. Add it to node_modules or the Tailwind runtime install." @@ -115,7 +117,7 @@ defmodule Volt.Tailwind.Resolver do case try_resolve(target, extensions, index_files) do {:ok, path} -> - path + Path.expand(path) {:error, reason} -> raise "Could not resolve file #{inspect(id)} from #{inspect(base)}: #{inspect(reason)}" diff --git a/lib/volt/test/case.ex b/lib/volt/test/case.ex index e29f440..9be3623 100644 --- a/lib/volt/test/case.ex +++ b/lib/volt/test/case.ex @@ -10,11 +10,17 @@ defmodule Volt.Test.Case do use ExUnit.CaseTemplate using opts do + browser? = Keyword.get(opts, :browser, false) + quote do import ExUnit.Assertions, except: [assert: 1, assert: 2] import Volt.Test.Assertions import Volt.Test.Sigils + if unquote(browser?) do + @moduletag browser_js: true + end + @volt_test_config unquote(opts) end end diff --git a/lib/volt/test/discovery.ex b/lib/volt/test/discovery.ex index 2f260c0..0cb067b 100644 --- a/lib/volt/test/discovery.ex +++ b/lib/volt/test/discovery.ex @@ -24,7 +24,7 @@ defmodule Volt.Test.Discovery do end defp discover(%Config{} = config) do - root = config.root + root = Path.expand(config.root) matched = expand_patterns(root, config.include) ignored = root |> expand_patterns(config.exclude) |> MapSet.new() diff --git a/test/mix/tasks/volt.build_test.exs b/test/mix/tasks/volt.build_test.exs index c9a04ab..741b083 100644 --- a/test/mix/tasks/volt.build_test.exs +++ b/test/mix/tasks/volt.build_test.exs @@ -7,7 +7,7 @@ defmodule Mix.Tasks.Volt.BuildTest do Mix.Task.reenable("volt.build") tmp_dir = - Path.join(System.tmp_dir!(), "volt-build-test-#{System.unique_integer([:positive])}") + Path.expand("volt-build-test-#{System.unique_integer([:positive])}", System.tmp_dir!()) File.mkdir_p!(Path.join(tmp_dir, "src")) diff --git a/test/mix/tasks/volt.js.check_test.exs b/test/mix/tasks/volt.js.check_test.exs index 581e0d4..350c405 100644 --- a/test/mix/tasks/volt.js.check_test.exs +++ b/test/mix/tasks/volt.js.check_test.exs @@ -155,46 +155,52 @@ defmodule Volt.JsCheckTest do end defp fake_tsgolint!(dir) do - path = Path.join(dir, "tsgolint") - - File.write!(path, """ - #!/bin/sh - elixir -e 'json = ~s({"rule":"no-floating-promises","message":{"description":"floating promise"},"file_path":"typed.ts","range":{"pos":0,"end":5}}); size = byte_size(json); File.write!("/dev/stdout", <>)' + fake_executable!(dir, "tsgolint", """ + json = ~s({"rule":"no-floating-promises","message":{"description":"floating promise"},"file_path":"typed.ts","range":{"pos":0,"end":5}}) + IO.binwrite(<>) """) - - File.chmod!(path, 0o755) - path end defp fake_tsgolint_capture!(dir) do - path = Path.join(dir, "tsgolint-capture") payload_path = Path.join(dir, "payload.json") - File.write!(path, """ - #!/bin/sh - cat > #{payload_path} + fake_executable!(dir, "tsgolint-capture", """ + File.write!(#{inspect(payload_path)}, IO.binread(:stdio, :eof)) """) - - File.chmod!(path, 0o755) - path end defp fake_tsgolint_unknown_retry!(dir) do - path = Path.join(dir, "tsgolint-unknown-retry") payload_path = Path.join(dir, "payload.json") input_path = Path.join(dir, "payload-attempt.json") - File.write!(path, """ - #!/bin/sh - cat > #{input_path} - if grep -q consistent-type-imports #{input_path}; then - echo 'panic: unknown rule: consistent-type-imports [recovered, repanicked]' >&2 - exit 2 - fi - cp #{input_path} #{payload_path} + fake_executable!(dir, "tsgolint-unknown-retry", """ + payload = IO.binread(:stdio, :eof) + File.write!(#{inspect(input_path)}, payload) + + if String.contains?(payload, "consistent-type-imports") do + IO.puts(:stderr, "panic: unknown rule: consistent-type-imports [recovered, repanicked]") + System.halt(2) + end + + File.write!(#{inspect(payload_path)}, payload) """) + end - File.chmod!(path, 0o755) - path + defp fake_executable!(dir, name, code) do + script = Path.expand("#{name}.exs", dir) + File.write!(script, code) + + case :os.type() do + {:win32, _name} -> + path = Path.expand("#{name}.cmd", dir) + File.write!(path, "@echo off\r\nelixir.bat \"#{script}\"\r\n") + path + + _unix -> + path = Path.expand(name, dir) + File.write!(path, "#!/bin/sh\nexec elixir \"#{script}\"\n") + File.chmod!(path, 0o755) + path + end end end diff --git a/test/support/volt/builder_case.exs b/test/support/volt/builder_case.exs index 1c16aa9..671f509 100644 --- a/test/support/volt/builder_case.exs +++ b/test/support/volt/builder_case.exs @@ -102,7 +102,7 @@ defmodule Volt.TestSupport.BuilderCase do alias Volt.TestSupport.BuilderCase.{EmbeddedBoxPlugin, JSLoaderPlugin, VirtualModPlugin} - @fixture_dir Path.join(System.tmp_dir!(), "volt-builder-test") + @fixture_dir Path.expand("volt-builder-test", System.tmp_dir!()) @outdir Path.join(@fixture_dir, "dist") setup do diff --git a/test/support/volt/dev_server_case.exs b/test/support/volt/dev_server_case.exs index 005e0af..0de24a2 100644 --- a/test/support/volt/dev_server_case.exs +++ b/test/support/volt/dev_server_case.exs @@ -83,7 +83,7 @@ defmodule Volt.TestSupport.DevServerCase do alias Volt.TestSupport.DevServerCase.{EmbeddedBoxPlugin, VirtualPlugin} - @fixture_dir Path.join(System.tmp_dir!(), "volt-dev-server-test") + @fixture_dir Path.expand("volt-dev-server-test", System.tmp_dir!()) setup do File.mkdir_p!(Path.join(@fixture_dir, "src")) diff --git a/test/volt/assets_test.exs b/test/volt/assets_test.exs index 78e3276..08af649 100644 --- a/test/volt/assets_test.exs +++ b/test/volt/assets_test.exs @@ -1,7 +1,7 @@ defmodule Volt.AssetsTest do use ExUnit.Case, async: true - @fixture_dir Path.join(System.tmp_dir!(), "volt-assets-test") + @fixture_dir Path.expand("volt-assets-test", System.tmp_dir!()) setup do File.mkdir_p!(@fixture_dir) diff --git a/test/volt/builder/bundle_test.exs b/test/volt/builder/bundle_test.exs index 7743a31..aa1a7a1 100644 --- a/test/volt/builder/bundle_test.exs +++ b/test/volt/builder/bundle_test.exs @@ -5,7 +5,10 @@ defmodule Volt.Builder.BundleTest do setup do tmp_dir = - Path.join(System.tmp_dir!(), "volt-builder-bundle-#{System.unique_integer([:positive])}") + Path.expand( + "volt-builder-bundle-#{System.unique_integer([:positive])}", + System.tmp_dir!() + ) File.mkdir_p!(tmp_dir) diff --git a/test/volt/builder/naming_test.exs b/test/volt/builder/naming_test.exs new file mode 100644 index 0000000..19a329c --- /dev/null +++ b/test/volt/builder/naming_test.exs @@ -0,0 +1,14 @@ +defmodule Volt.Builder.NamingTest do + use ExUnit.Case, async: true + + alias Volt.Builder.Naming + + test "replaces characters that are invalid in Windows filenames" do + assert Naming.file_path("virtual:volt/test?|.js") == + "virtual_volt/test__entry__.js" + end + + test "normalizes separators, trailing dots, and reserved Windows names" do + assert Naming.file_path("scope\\CON.js/trailing. ") == "scope/_CON.js/trailing" + end +end diff --git a/test/volt/builder/output/css_test.exs b/test/volt/builder/output/css_test.exs index c0028c8..6ba1b87 100644 --- a/test/volt/builder/output/css_test.exs +++ b/test/volt/builder/output/css_test.exs @@ -1,7 +1,7 @@ defmodule Volt.Builder.Output.CSSTest do use ExUnit.Case, async: false - @fixture_dir Path.join(System.tmp_dir!(), "volt-builder-css-test") + @fixture_dir Path.expand("volt-builder-css-test", System.tmp_dir!()) @outdir Path.join(@fixture_dir, "dist") setup do diff --git a/test/volt/builder/plugins_and_virtual_modules_test.exs b/test/volt/builder/plugins_and_virtual_modules_test.exs index 5e2cbd1..8190a5c 100644 --- a/test/volt/builder/plugins_and_virtual_modules_test.exs +++ b/test/volt/builder/plugins_and_virtual_modules_test.exs @@ -306,8 +306,8 @@ defmodule Volt.Builder.PluginsAndVirtualModulesTest do plugins: [VirtualModPlugin] ) - assert result.manifest["virtual:plain.js"].file == "virtual:plain.js" - assert File.read!(Path.join(@outdir, "virtual:plain.js")) =~ "123" + assert result.manifest["virtual_plain.js"].file == "virtual_plain.js" + assert File.read!(Path.join(@outdir, "virtual_plain.js")) =~ "123" end test "multiple virtual build entries get stable manifest keys" do diff --git a/test/volt/chunk_graph_test.exs b/test/volt/chunk_graph_test.exs index 8dcbc43..49342a0 100644 --- a/test/volt/chunk_graph_test.exs +++ b/test/volt/chunk_graph_test.exs @@ -135,28 +135,27 @@ defmodule Volt.ChunkGraphTest do end test "records static and dynamic chunk links" do + main = Path.expand("app/main.ts") + static_vendor = Path.expand("app/static-vendor.ts") + lazy = Path.expand("app/lazy.ts") + modules = [ - {"/app/main.ts", "main.ts", ""}, - {"/app/static-vendor.ts", "static-vendor.ts", ""}, - {"/app/lazy.ts", "lazy.ts", ""} + {main, "main.ts", ""}, + {static_vendor, "static-vendor.ts", ""}, + {lazy, "lazy.ts", ""} ] dep_map = %{ - "/app/main.ts" => %{ - static: ["/app/static-vendor.ts"], - dynamic: ["/app/lazy.ts"] - }, - "/app/static-vendor.ts" => %{static: [], dynamic: []}, - "/app/lazy.ts" => %{static: [], dynamic: []} + main => %{static: [static_vendor], dynamic: [lazy]}, + static_vendor => %{static: [], dynamic: []}, + lazy => %{static: [], dynamic: []} } graph = - ChunkGraph.build("/app/main.ts", modules, dep_map, - manual_chunks: %{"vendor" => ["/app/static-vendor.ts"]} - ) + ChunkGraph.build(main, modules, dep_map, manual_chunks: %{"vendor" => [static_vendor]}) assert graph.chunks["entry"].imports == ["vendor"] - assert graph.chunks["entry"].dynamic_imports == [graph.module_to_chunk["/app/lazy.ts"]] + assert graph.chunks["entry"].dynamic_imports == [graph.module_to_chunk[lazy]] end test "module_to_chunk maps dynamic entry to async chunk" do @@ -232,37 +231,33 @@ defmodule Volt.ChunkGraphTest do end test "path patterns match by directory prefix" do + main = Path.expand("app/main.ts") + button = Path.expand("app/src/components/Button.ts") + modal = Path.expand("app/src/components/Modal.ts") + utils = Path.expand("app/src/utils.ts") + modules = [ - {"/app/main.ts", "main.ts", ""}, - {"/app/src/components/Button.ts", "Button.ts", ""}, - {"/app/src/components/Modal.ts", "Modal.ts", ""}, - {"/app/src/utils.ts", "utils.ts", ""} + {main, "main.ts", ""}, + {button, "Button.ts", ""}, + {modal, "Modal.ts", ""}, + {utils, "utils.ts", ""} ] dep_map = %{ - "/app/main.ts" => %{ - static: [ - "/app/src/components/Button.ts", - "/app/src/components/Modal.ts", - "/app/src/utils.ts" - ], - dynamic: [] - }, - "/app/src/components/Button.ts" => %{static: [], dynamic: []}, - "/app/src/components/Modal.ts" => %{static: [], dynamic: []}, - "/app/src/utils.ts" => %{static: [], dynamic: []} + main => %{static: [button, modal, utils], dynamic: []}, + button => %{static: [], dynamic: []}, + modal => %{static: [], dynamic: []}, + utils => %{static: [], dynamic: []} } graph = - ChunkGraph.build("/app/main.ts", modules, dep_map, - manual_chunks: %{"ui" => ["/app/src/components"]} - ) + ChunkGraph.build(main, modules, dep_map, manual_chunks: %{"ui" => [Path.dirname(button)]}) assert graph.chunks["ui"].type == :manual - assert "/app/src/components/Button.ts" in graph.chunks["ui"].modules - assert "/app/src/components/Modal.ts" in graph.chunks["ui"].modules + assert button in graph.chunks["ui"].modules + assert modal in graph.chunks["ui"].modules - refute "/app/src/utils.ts" in graph.chunks["ui"].modules + refute utils in graph.chunks["ui"].modules end test "manual chunks coexist with dynamic imports" do diff --git a/test/volt/css/asset_url_rewriter_test.exs b/test/volt/css/asset_url_rewriter_test.exs index 7812768..700a5e8 100644 --- a/test/volt/css/asset_url_rewriter_test.exs +++ b/test/volt/css/asset_url_rewriter_test.exs @@ -1,7 +1,7 @@ defmodule Volt.CSS.AssetURLRewriterTest do use ExUnit.Case, async: true - @fixture_dir Path.join(System.tmp_dir!(), "volt-css-asset-url-rewriter-test") + @fixture_dir Path.expand("volt-css-asset-url-rewriter-test", System.tmp_dir!()) @outdir Path.join(@fixture_dir, "dist") setup do diff --git a/test/volt/css/dependencies_test.exs b/test/volt/css/dependencies_test.exs index 9143f4e..80c0f4d 100644 --- a/test/volt/css/dependencies_test.exs +++ b/test/volt/css/dependencies_test.exs @@ -1,7 +1,7 @@ defmodule Volt.CSS.DependenciesTest do use ExUnit.Case, async: true - @root Path.join(System.tmp_dir!(), "volt-css-dependencies-test") + @root Path.expand("volt-css-dependencies-test", System.tmp_dir!()) setup do File.rm_rf!(@root) diff --git a/test/volt/dev_test.exs b/test/volt/dev_test.exs index 0b44782..b391b8f 100644 --- a/test/volt/dev_test.exs +++ b/test/volt/dev_test.exs @@ -2,7 +2,7 @@ defmodule Volt.DevTest do use ExUnit.Case, async: false test "starts one supervised watcher per profile and asset root" do - root = Path.join(System.tmp_dir!(), "volt-dev-#{System.unique_integer([:positive])}") + 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) diff --git a/test/volt/hmr/boundary_test.exs b/test/volt/hmr/boundary_test.exs index 1cf5e65..3d428c1 100644 --- a/test/volt/hmr/boundary_test.exs +++ b/test/volt/hmr/boundary_test.exs @@ -63,30 +63,30 @@ defmodule Volt.HMR.BoundaryTest do end test "finds dependency-accepting boundary in parent module through HMR module graph" do + app = Path.expand("app/App.tsx") + button = Path.expand("app/Button.tsx") + Volt.HMR.ModuleGraph.update_module( "/assets/App.tsx", "/assets/App.tsx", - "/app/App.tsx", + app, ["/assets/Button.tsx"] ) Volt.HMR.ModuleGraph.update_module( "/assets/Button.tsx", "/assets/Button.tsx", - "/app/Button.tsx", + button, [] ) - read = fn - "/app/App.tsx" -> - "import Button from './Button'\nif (import.meta.hot) { import.meta.hot.accept('./Button', () => {}) }" + sources = %{ + app => + "import Button from './Button'\nif (import.meta.hot) { import.meta.hot.accept('./Button', () => {}) }", + button => "export default function Button() {}" + } - "/app/Button.tsx" -> - "export default function Button() {}" - end - - assert {:ok, "/app/App.tsx"} = - Boundary.find_boundary("/app/Button.tsx", read) + assert {:ok, ^app} = Boundary.find_boundary(button, &Map.fetch!(sources, &1)) end test "finds boundary in parent module through HMR module graph" do diff --git a/test/volt/hmr/glob_graph_test.exs b/test/volt/hmr/glob_graph_test.exs index 912f89b..ec8ad0e 100644 --- a/test/volt/hmr/glob_graph_test.exs +++ b/test/volt/hmr/glob_graph_test.exs @@ -24,9 +24,11 @@ defmodule Volt.HMR.GlobGraphTest do test "extracts patterns from import.meta.glob source" do source = "const pages = import.meta.glob('./pages/*.ts')" + importer = Path.expand("src/routes.ts") + page = Path.expand("src/pages/home.ts") - GlobGraph.update_from_source("/src/routes.ts", source) + GlobGraph.update_from_source(importer, source) - assert GlobGraph.dependents("/src/pages/home.ts") == ["/src/routes.ts"] + assert GlobGraph.dependents(page) == [importer] end end diff --git a/test/volt/hmr/style_dependencies_test.exs b/test/volt/hmr/style_dependencies_test.exs index e38da27..a2afb94 100644 --- a/test/volt/hmr/style_dependencies_test.exs +++ b/test/volt/hmr/style_dependencies_test.exs @@ -1,7 +1,7 @@ defmodule Volt.HMR.StyleDependenciesTest do use ExUnit.Case, async: false - @root Path.join(System.tmp_dir!(), "volt-style-dependencies-test") + @root Path.expand("volt-style-dependencies-test", System.tmp_dir!()) setup do Volt.HMR.StyleGraph.clear() diff --git a/test/volt/js/helpers_test.exs b/test/volt/js/helpers_test.exs index a672b18..8eea207 100644 --- a/test/volt/js/helpers_test.exs +++ b/test/volt/js/helpers_test.exs @@ -5,7 +5,10 @@ defmodule Volt.JS.HelpersTest do setup do tmp_dir = - Path.join(System.tmp_dir!(), "volt-js-helpers-#{System.unique_integer([:positive])}") + Path.expand( + "volt-js-helpers-#{System.unique_integer([:positive])}", + System.tmp_dir!() + ) original_format = Application.get_env(:volt, :format) original_lint = Application.get_env(:volt, :lint) diff --git a/test/volt/js/resolver_test.exs b/test/volt/js/resolver_test.exs index 2592470..1b7dcd2 100644 --- a/test/volt/js/resolver_test.exs +++ b/test/volt/js/resolver_test.exs @@ -3,8 +3,9 @@ defmodule Volt.JS.ResolverTest do describe "resolve/2" do test "resolves exact alias" do - aliases = %{"@" => "/app/assets/src"} - assert {:ok, "/app/assets/src"} = Volt.JS.Resolver.resolve("@", aliases) + target = Path.expand("app/assets/src") + aliases = %{"@" => target} + assert {:ok, ^target} = Volt.JS.Resolver.resolve("@", aliases) end test "resolves alias with subpath" do diff --git a/test/volt/plugin_runner_test.exs b/test/volt/plugin_runner_test.exs index d705b3d..88242cd 100644 --- a/test/volt/plugin_runner_test.exs +++ b/test/volt/plugin_runner_test.exs @@ -161,7 +161,7 @@ defmodule Volt.PluginRunnerTest do end test "loads embedded modules by query id" do - path = Path.join(System.tmp_dir!(), "volt-plugin-runner-card.box") + path = Path.expand("volt-plugin-runner-card.box", System.tmp_dir!()) File.write!(path, "export const value: number = 1") id = diff --git a/test/volt/priv_test.exs b/test/volt/priv_test.exs index b4b01c4..7511fce 100644 --- a/test/volt/priv_test.exs +++ b/test/volt/priv_test.exs @@ -69,6 +69,7 @@ defmodule Volt.PrivTest do test "rejects paths outside priv" do assert_raise ArgumentError, fn -> Volt.Priv.path(@support_modules, "../secret.ts") end - assert_raise ArgumentError, fn -> Volt.Priv.path(@support_modules, "/tmp/secret.ts") end + outside = Path.expand("secret.ts", System.tmp_dir!()) + assert_raise ArgumentError, fn -> Volt.Priv.path(@support_modules, outside) end end end diff --git a/test/volt/test/discovery_test.exs b/test/volt/test/discovery_test.exs index 1ac25d6..a25d2b4 100644 --- a/test/volt/test/discovery_test.exs +++ b/test/volt/test/discovery_test.exs @@ -22,8 +22,8 @@ defmodule Volt.Test.DiscoveryTest do config = %Config{root: tmp_dir, include: ["**/*.{test,spec}.{js,ts}"]} assert Volt.Test.Discovery.files(config) == [ - Path.join([tmp_dir, "src", "a.spec.js"]), - Path.join([tmp_dir, "src", "b.test.ts"]) + Path.expand(Path.join([tmp_dir, "src", "a.spec.js"])), + Path.expand(Path.join([tmp_dir, "src", "b.test.ts"])) ] end @@ -38,7 +38,9 @@ defmodule Volt.Test.DiscoveryTest do exclude: ["src/generated/**", "node_modules/**"] } - assert Volt.Test.Discovery.files(config) == [Path.join([tmp_dir, "src", "app.test.ts"])] + assert Volt.Test.Discovery.files(config) == [ + Path.expand(Path.join([tmp_dir, "src", "app.test.ts"])) + ] end test "reads config when called with profile and overrides", %{tmp_dir: tmp_dir} do @@ -63,7 +65,7 @@ defmodule Volt.Test.DiscoveryTest do ) assert Volt.Test.Discovery.files(:my_app_web, []) == [ - Path.join([tmp_dir, "app", "app.test.ts"]) + Path.expand(Path.join([tmp_dir, "app", "app.test.ts"])) ] end diff --git a/test/volt/test/ex_unit_test.exs b/test/volt/test/ex_unit_test.exs index 23418c1..3e412e5 100644 --- a/test/volt/test/ex_unit_test.exs +++ b/test/volt/test/ex_unit_test.exs @@ -160,7 +160,7 @@ defmodule Volt.Test.ExUnitTest do defp test_tag(test, key), do: Map.fetch!(test, key) defp write!(root, path, contents) do - path = Path.join(root, path) + path = Path.expand(path, root) File.mkdir_p!(Path.dirname(path)) File.write!(path, contents) path diff --git a/test/volt/test/runner_test.exs b/test/volt/test/runner_test.exs index 9d27c82..abc25e2 100644 --- a/test/volt/test/runner_test.exs +++ b/test/volt/test/runner_test.exs @@ -186,7 +186,7 @@ defmodule Volt.Test.RunnerTest do end defp write!(root, path, contents) do - path = Path.join(root, path) + path = Path.expand(path, root) File.mkdir_p!(Path.dirname(path)) File.write!(path, contents) path diff --git a/test/volt/watcher_test.exs b/test/volt/watcher_test.exs index a522313..45321b1 100644 --- a/test/volt/watcher_test.exs +++ b/test/volt/watcher_test.exs @@ -2,7 +2,9 @@ defmodule Volt.WatcherTest do use ExUnit.Case, async: false setup %{test: test_name} do - watch_dir = Path.join([System.tmp_dir!(), "volt-watcher-test", to_string(test_name)]) + watch_dir = + Path.expand(Path.join("volt-watcher-test", to_string(test_name)), System.tmp_dir!()) + File.mkdir_p!(watch_dir) Volt.HMR.ImportGraph.clear() Volt.Cache.clear() From e72b70f61f2ec4fe0383dc0769a056a71fc6ecf6 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 20 Jul 2026 13:08:22 +0200 Subject: [PATCH 026/128] Disambiguate filesystem-safe entry names --- CHANGELOG.md | 1 + lib/volt/builder.ex | 27 ++++++++++++++++++- lib/volt/builder/collector.ex | 2 +- test/support/volt/builder_case.exs | 4 +++ .../plugins_and_virtual_modules_test.exs | 19 +++++++++++++ 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf36a55..7b857c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Generate filesystem-safe entry and module labels for virtual modules on Windows. +- Keep entry and module labels unique when filesystem sanitization produces collisions. - Use platform-aware absolute paths for builder, test discovery, glob transforms, and Tailwind resolution. - Make type-aware lint test executables portable across Unix and Windows. diff --git a/lib/volt/builder.ex b/lib/volt/builder.ex index 2afbdab..c92412e 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -64,7 +64,7 @@ defmodule Volt.Builder do Volt.PublicDir.copy(public_dir, Path.dirname(build_ctx.outdir)) - expanded_entries = Enum.flat_map(entries, &expand_entry(&1, name)) + expanded_entries = entries |> Enum.flat_map(&expand_entry(&1, name)) |> unique_entry_names() results = if shared_entries?(expanded_entries, build_ctx, name) do @@ -804,6 +804,31 @@ defmodule Volt.Builder do end end + defp unique_entry_names(entries) do + {entries, _used_names} = + Enum.map_reduce(entries, MapSet.new(), fn {entry, type, name}, used_names -> + unique_name = unique_entry_name(name, entry, used_names) + {{entry, type, unique_name}, MapSet.put(used_names, unique_name)} + end) + + entries + end + + defp unique_entry_name(name, entry, used_names) do + if MapSet.member?(used_names, name) do + digest = :crypto.hash(:sha256, entry) |> Base.encode16(case: :lower) |> binary_part(0, 10) + candidate = "#{name}-#{digest}" + + if MapSet.member?(used_names, candidate) do + unique_entry_name(candidate, entry, used_names) + else + candidate + end + else + name + end + end + defp normalize_package_scopes(scopes) when is_map(scopes) or is_list(scopes) do scopes |> Enum.map(fn {source_root, package_dir} -> diff --git a/lib/volt/builder/collector.ex b/lib/volt/builder/collector.ex index 197dee0..5d87078 100644 --- a/lib/volt/builder/collector.ex +++ b/lib/volt/builder/collector.ex @@ -362,7 +362,7 @@ defmodule Volt.Builder.Collector do defp deduplicate_label(label, resolved_path, used) do if MapSet.member?(used, label) do - parent = resolved_path |> Path.dirname() |> Path.basename() + parent = resolved_path |> Path.dirname() |> Path.basename() |> Naming.file_path() candidate = parent <> "/" <> label if MapSet.member?(used, candidate) do diff --git a/test/support/volt/builder_case.exs b/test/support/volt/builder_case.exs index 671f509..00356d0 100644 --- a/test/support/volt/builder_case.exs +++ b/test/support/volt/builder_case.exs @@ -74,6 +74,8 @@ defmodule Volt.TestSupport.BuilderCase do def resolve("virtual:site/entry-a", _), do: {:ok, "virtual:site/entry-a"} def resolve("virtual:site/entry-b", _), do: {:ok, "virtual:site/entry-b"} def resolve("virtual:site/style-entry", _), do: {:ok, "virtual:site/style-entry"} + def resolve("virtual:collision/entry:a", _), do: {:ok, "virtual:collision/entry:a"} + def resolve("virtual:collision/entry|a", _), do: {:ok, "virtual:collision/entry|a"} def resolve("virtual:plain", _), do: {:ok, "virtual:plain"} def resolve("./style.css", "virtual:site/style-entry"), @@ -91,6 +93,8 @@ defmodule Volt.TestSupport.BuilderCase do def load("virtual:site/style-entry"), do: {:ok, "import './style.css'; console.log('with css');"} + def load("virtual:collision/entry:a"), do: {:ok, "console.log('colon entry');"} + def load("virtual:collision/entry|a"), do: {:ok, "console.log('pipe entry');"} def load("virtual:plain"), do: {:ok, "export default 123;"} def load(_), do: nil diff --git a/test/volt/builder/plugins_and_virtual_modules_test.exs b/test/volt/builder/plugins_and_virtual_modules_test.exs index 8190a5c..ccd965d 100644 --- a/test/volt/builder/plugins_and_virtual_modules_test.exs +++ b/test/volt/builder/plugins_and_virtual_modules_test.exs @@ -327,6 +327,25 @@ defmodule Volt.Builder.PluginsAndVirtualModulesTest do assert result.manifest["entry-b.js"].file == "entry-b.js" end + test "virtual entry names remain unique after filesystem sanitization" do + {:ok, result} = + Volt.Builder.build( + entry: ["virtual:collision/entry:a", "virtual:collision/entry|a"], + outdir: @outdir, + hash: false, + minify: false, + sourcemap: false, + plugins: [VirtualModPlugin] + ) + + files = result.manifest |> Map.values() |> Enum.map(& &1.file) |> Enum.sort() + + assert length(files) == 2 + assert Enum.uniq(files) == files + assert Enum.any?(files, &(&1 == "entry_a.js")) + assert Enum.any?(files, &String.match?(&1, ~r/^entry_a-[0-9a-f]{10}\.js$/)) + end + test "virtual build entries can import stylesheet dependencies" do File.write!(Path.join(@fixture_dir, "src/style.css"), ".foo { color: red }") From e948cedbd3f43ff1dfae20662943f3f9b621ea2c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 20 Jul 2026 13:55:17 +0200 Subject: [PATCH 027/128] Centralize builder naming logic --- lib/volt/builder.ex | 29 ++--------- lib/volt/builder/collector.ex | 62 +----------------------- lib/volt/builder/naming.ex | 80 +++++++++++++++++++++++++++++++ test/volt/builder/naming_test.exs | 33 +++++++++++++ 4 files changed, 119 insertions(+), 85 deletions(-) diff --git a/lib/volt/builder.ex b/lib/volt/builder.ex index c92412e..8e293d1 100644 --- a/lib/volt/builder.ex +++ b/lib/volt/builder.ex @@ -789,17 +789,11 @@ defmodule Volt.Builder do if Volt.HTMLEntry.html?(entry) do {:ok, %{scripts: scripts, styles: styles}} = Volt.HTMLEntry.extract(entry) - Enum.map( - scripts, - &{&1, :script, &1 |> Path.basename() |> Path.rootname() |> Naming.file_path()} - ) ++ - Enum.map( - styles, - &{&1, :style, &1 |> Path.basename() |> Path.rootname() |> Naming.file_path()} - ) + Enum.map(scripts, &{&1, :script, Naming.entry_name(&1)}) ++ + Enum.map(styles, &{&1, :style, Naming.entry_name(&1)}) else type = if Path.extname(entry) in @css_exts, do: :style, else: :script - entry_name = Naming.file_path(override_name || entry |> Path.basename() |> Path.rootname()) + entry_name = Naming.entry_name(entry, override_name) [{entry, type, entry_name}] end end @@ -807,28 +801,13 @@ defmodule Volt.Builder do defp unique_entry_names(entries) do {entries, _used_names} = Enum.map_reduce(entries, MapSet.new(), fn {entry, type, name}, used_names -> - unique_name = unique_entry_name(name, entry, used_names) + unique_name = Naming.unique(name, entry, used_names) {{entry, type, unique_name}, MapSet.put(used_names, unique_name)} end) entries end - defp unique_entry_name(name, entry, used_names) do - if MapSet.member?(used_names, name) do - digest = :crypto.hash(:sha256, entry) |> Base.encode16(case: :lower) |> binary_part(0, 10) - candidate = "#{name}-#{digest}" - - if MapSet.member?(used_names, candidate) do - unique_entry_name(candidate, entry, used_names) - else - candidate - end - else - name - end - end - defp normalize_package_scopes(scopes) when is_map(scopes) or is_list(scopes) do scopes |> Enum.map(fn {source_root, package_dir} -> diff --git a/lib/volt/builder/collector.ex b/lib/volt/builder/collector.ex index 5d87078..6960091 100644 --- a/lib/volt/builder/collector.ex +++ b/lib/volt/builder/collector.ex @@ -305,8 +305,8 @@ defmodule Volt.Builder.Collector do end defp unique_label(_specifier, resolved_path, state) do - base_label = module_label(resolved_path, state.root) - label = deduplicate_label(base_label, resolved_path, state.used_labels) + base_label = Naming.module_label(resolved_path, state.root) + label = Naming.unique(base_label, resolved_path, state.used_labels) state = %{ state @@ -317,64 +317,6 @@ defmodule Volt.Builder.Collector do {label, state} end - defp module_label(resolved_path, root) do - {path, query} = Volt.URL.split_query(resolved_path) - [relative_path | rest] = path |> String.split("/node_modules/") |> Enum.reverse() - - label = - if rest != [] do - relative_path - else - relative = Path.relative_to(path, root) - - if Path.type(relative) == :absolute do - "_external/" <> - Path.basename(Path.dirname(path)) <> "/" <> Path.basename(path) - else - relative - end - end - |> with_query_suffix(query) - - label = - cond do - Path.extname(label) == ".json" -> Path.rootname(label) <> ".json.js" - Path.extname(label) in Volt.JS.Extensions.css() -> label <> ".js" - query != "" -> label <> ".js" - true -> label - end - - Naming.file_path(label) - end - - defp with_query_suffix(label, ""), do: label - - defp with_query_suffix(label, query) do - suffix = - query - |> URI.decode_query() - |> Map.keys() - |> Enum.sort() - |> Enum.join("-") - - label <> "." <> suffix - end - - defp deduplicate_label(label, resolved_path, used) do - if MapSet.member?(used, label) do - parent = resolved_path |> Path.dirname() |> Path.basename() |> Naming.file_path() - candidate = parent <> "/" <> label - - if MapSet.member?(used, candidate) do - deduplicate_label(candidate <> "_2", resolved_path, used) - else - candidate - end - else - label - end - end - defp split_imports(typed_imports) do {statics, dynamics} = Enum.reduce(typed_imports, {[], []}, fn diff --git a/lib/volt/builder/naming.ex b/lib/volt/builder/naming.ex index 88505cf..645ed49 100644 --- a/lib/volt/builder/naming.ex +++ b/lib/volt/builder/naming.ex @@ -3,6 +3,48 @@ 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 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) + def entry_name(path, nil), do: path |> Path.basename() |> Path.rootname() |> file_path() + + @spec module_label(String.t(), String.t()) :: String.t() + def module_label(resolved_path, root) do + {path, query} = Volt.URL.split_query(resolved_path) + [relative_path | rest] = path |> String.split("/node_modules/") |> Enum.reverse() + + label = + if rest != [] do + relative_path + else + relative = Path.relative_to(path, root) + + if Path.type(relative) == :absolute do + "_external/" <> + Path.basename(Path.dirname(path)) <> "/" <> Path.basename(path) + else + relative + end + end + |> with_query_suffix(query) + + label + |> ensure_javascript_extension(query) + |> file_path() + end + + @spec unique(String.t(), String.t(), MapSet.t(String.t())) :: String.t() + def unique(label, identity, used_labels) do + if MapSet.member?(used_labels, label) do + digest = + :crypto.hash(:sha256, identity) |> Base.encode16(case: :lower) |> binary_part(0, 10) + + unique_candidate(label, digest, used_labels, 0) + else + label + end + end + @spec file_path(String.t()) :: String.t() def file_path(path) do path @@ -11,6 +53,44 @@ defmodule Volt.Builder.Naming do |> Enum.map_join("/", &file_segment/1) end + defp unique_candidate(label, digest, used_labels, attempt) do + suffix = if attempt == 0, do: digest, else: "#{digest}-#{attempt + 1}" + candidate = append_suffix(label, suffix) + + if MapSet.member?(used_labels, candidate) do + unique_candidate(label, digest, used_labels, attempt + 1) + else + candidate + end + end + + defp append_suffix(path, suffix) do + extension = Path.extname(path) + Path.rootname(path) <> "-" <> suffix <> extension + end + + defp with_query_suffix(label, ""), do: label + + defp with_query_suffix(label, query) do + suffix = + query + |> URI.decode_query() + |> Map.keys() + |> Enum.sort() + |> Enum.join("-") + + label <> "." <> suffix + end + + defp ensure_javascript_extension(label, query) do + cond do + Path.extname(label) == ".json" -> Path.rootname(label) <> ".json.js" + Path.extname(label) in Volt.JS.Extensions.css() -> label <> ".js" + query != "" -> label <> ".js" + true -> label + end + end + defp file_segment(segment) do segment = segment diff --git a/test/volt/builder/naming_test.exs b/test/volt/builder/naming_test.exs index 19a329c..09f79c0 100644 --- a/test/volt/builder/naming_test.exs +++ b/test/volt/builder/naming_test.exs @@ -11,4 +11,37 @@ defmodule Volt.Builder.NamingTest do test "normalizes separators, trailing dots, and reserved Windows names" do assert Naming.file_path("scope\\CON.js/trailing. ") == "scope/_CON.js/trailing" end + + test "derives filesystem-safe entry names" do + assert Naming.entry_name("virtual:volt/test.ts") == "test" + assert Naming.entry_name("ignored.ts", "CON:entry") == "CON_entry" + end + + test "derives module labels and JavaScript wrapper extensions" do + root = Path.join(System.tmp_dir!(), "volt-naming-root") + + assert Naming.module_label(Path.join(root, "src/data.json"), root) == "src/data.json.js" + + assert Naming.module_label(Path.join(root, "src/style.css") <> "?raw", root) == + "src/style.css.raw.js" + + assert Naming.module_label(Path.join(root, "node_modules/pkg/index.js"), root) == + "pkg/index.js" + end + + test "adds stable unique suffixes before file extensions" do + used = MapSet.new(["src/index.js"]) + unique = Naming.unique("src/index.js", "virtual:first", used) + + assert unique =~ ~r|^src/index-[0-9a-f]{10}\.js$| + assert Naming.unique("src/index.js", "virtual:first", used) == unique + assert Naming.unique("src/other.js", "virtual:other", used) == "src/other.js" + end + + test "increments a stable suffix when the digest candidate is also used" do + first = Naming.unique("entry", "virtual:entry", MapSet.new(["entry"])) + used = MapSet.new(["entry", first]) + + assert Naming.unique("entry", "virtual:entry", used) == first <> "-2" + end end From a5f037e002318269ef3618c125e854955ad98389 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 27 Jul 2026 14:32:11 +0200 Subject: [PATCH 028/128] Update GlobEx for Windows roots --- CHANGELOG.md | 1 + mix.exs | 2 +- mix.lock | 2 +- test/volt/hmr/glob_graph_test.exs | 6 ++++++ 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b857c3..57c4d4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Keep entry and module labels unique when filesystem sanitization produces collisions. - Use platform-aware absolute paths for builder, test discovery, glob transforms, and Tailwind resolution. - Make type-aware lint test executables portable across Unix and Windows. +- Update GlobEx for Windows-rooted glob matching. ## 0.17.10 - 2026-07-17 diff --git a/mix.exs b/mix.exs index dace3ee..07d6145 100644 --- a/mix.exs +++ b/mix.exs @@ -34,7 +34,7 @@ defmodule Volt.MixProject do defp deps do [ {:reach, "~> 2.6.1", only: [:dev, :test], runtime: false}, - {:glob_ex, "~> 0.1"}, + {:glob_ex, "~> 0.1.12"}, {:oxc, "~> 0.17.7"}, {:vize, "~> 0.14.0"}, {:oxide_ex, "~> 0.2.1"}, diff --git a/mix.lock b/mix.lock index 5c67e18..42468bd 100644 --- a/mix.lock +++ b/mix.lock @@ -19,7 +19,7 @@ "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.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"}, - "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, + "glob_ex": {:hex, :glob_ex, "0.1.12", "7b2d9369c20e2697efcfd185d13d6e84c94cd3bfd2730fbde613141c2e015c00", [:mix], [], "hexpm", "2e2fac83f113514434c7eaf267b4c38af2f91766f1cab2c5db7053b7fc1ee0bb"}, "hex_solver": {:hex, :hex_solver, "0.2.3", "0d2ee20fbceb251d573f03ef34852e325529c2874ab66d1b576384021318996c", [:mix], [], "hexpm", "9daeae2ea6b8ad3dc7f51a10484f6bc1d0f705c31063383830a7167ab93b887b"}, "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"}, diff --git a/test/volt/hmr/glob_graph_test.exs b/test/volt/hmr/glob_graph_test.exs index ec8ad0e..b033417 100644 --- a/test/volt/hmr/glob_graph_test.exs +++ b/test/volt/hmr/glob_graph_test.exs @@ -15,6 +15,12 @@ defmodule Volt.HMR.GlobGraphTest do assert GlobGraph.dependents("/src/components/home.ts") == [] end + test "matches Windows drive letters case-insensitively" do + GlobGraph.update("C:/src/routes.ts", ["c:/src/pages/*.ts"]) + + assert GlobGraph.dependents("C:/src/pages/home.ts") == ["C:/src/routes.ts"] + end + test "honors negated patterns" do GlobGraph.update("/src/routes.ts", ["/src/pages/*.ts", "!/src/pages/*.test.ts"]) From 632e96b39eca2cb1abd74f38b42cf34b3f4566b5 Mon Sep 17 00:00:00 2001 From: sergeivaskov Date: Mon, 10 Aug 2026 00:15:28 +0300 Subject: [PATCH 029/128] fix: lock built-in JavaScript runtimes --- CHANGELOG.md | 6 + lib/volt/js/runtime.ex | 14 +- lib/volt/js/runtime/package_set.ex | 43 +++++ lib/volt/plugin/solid.ex | 13 +- lib/volt/plugin/svelte.ex | 13 +- lib/volt/tailwind.ex | 5 +- lib/volt/tailwind/loader.ex | 8 +- priv/npm/solid.lock | 126 +++++++++++++ priv/npm/svelte.lock | 205 ++++++++++++++++++++++ priv/npm/tailwind.lock | 58 ++++++ scripts/update_runtime_locks.exs | 62 +++++++ test/volt/js/runtime/package_set_test.exs | 60 +++++++ test/volt/js/runtime_test.exs | 37 +++- 13 files changed, 635 insertions(+), 15 deletions(-) create mode 100644 lib/volt/js/runtime/package_set.ex create mode 100644 priv/npm/solid.lock create mode 100644 priv/npm/svelte.lock create mode 100644 priv/npm/tailwind.lock create mode 100644 scripts/update_runtime_locks.exs create mode 100644 test/volt/js/runtime/package_set_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 49f9f1a..f368c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Fixed + +- Install built-in Svelte, Solid, and Tailwind runtimes from package-owned npm_ex lockfiles, and include each lock digest in the named runtime signature. + ## 0.17.10 - 2026-07-17 ### Added diff --git a/lib/volt/js/runtime.ex b/lib/volt/js/runtime.ex index c9274b4..3a50936 100644 --- a/lib/volt/js/runtime.ex +++ b/lib/volt/js/runtime.ex @@ -267,12 +267,24 @@ defmodule Volt.JS.Runtime do defp runtime_signature(opts) do opts - |> Keyword.take([:packages, :install_dir, :entry, :bundle]) + |> Keyword.take([:packages, :install_dir, :entry, :bundle, :lockfile]) + |> digest_lockfile() |> Enum.sort() |> :erlang.term_to_binary() |> :erlang.md5() end + defp digest_lockfile(opts) do + case Keyword.fetch(opts, :lockfile) do + {:ok, path} when is_binary(path) -> + digest = path |> File.read!() |> then(&:crypto.hash(:sha256, &1)) + Keyword.put(opts, :lockfile, {:sha256, digest}) + + _ -> + opts + end + end + defp maybe_put(opts, _key, nil), do: opts defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value) end diff --git a/lib/volt/js/runtime/package_set.ex b/lib/volt/js/runtime/package_set.ex new file mode 100644 index 0000000..ad690d9 --- /dev/null +++ b/lib/volt/js/runtime/package_set.ex @@ -0,0 +1,43 @@ +defmodule Volt.JS.Runtime.PackageSet do + @moduledoc false + + alias Volt.JS.Runtime.Installer + + @enforce_keys [:app, :name, :packages] + defstruct [:app, :name, :packages] + + @type t :: %__MODULE__{ + app: atom(), + name: atom(), + packages: %{required(String.t()) => String.t()} + } + + @spec bundled(atom(), atom(), map()) :: t() + def bundled(app, name, packages) + when is_atom(app) and is_atom(name) and is_map(packages) do + %__MODULE__{app: app, name: name, packages: packages} + end + + @spec install!(t(), keyword()) :: %{install_dir: String.t(), node_modules: String.t()} + def install!(%__MODULE__{} = set, opts \\ []) do + Installer.install!(set.packages, lock_opts(set, opts)) + end + + @spec runtime_opts(t(), keyword()) :: keyword() + def runtime_opts(%__MODULE__{} = set, opts \\ []) do + set + |> lock_opts(opts) + |> Keyword.put(:packages, set.packages) + end + + @spec lockfile_path(t()) :: String.t() + def lockfile_path(%__MODULE__{app: app, name: name}) do + Volt.Priv.path({app, "npm"}, "#{name}.lock") + end + + defp lock_opts(set, opts) do + opts + |> Keyword.delete(:packages) + |> Keyword.put(:lockfile, lockfile_path(set)) + end +end diff --git a/lib/volt/plugin/solid.ex b/lib/volt/plugin/solid.ex index 509d06c..91162f8 100644 --- a/lib/volt/plugin/solid.ex +++ b/lib/volt/plugin/solid.ex @@ -11,10 +11,13 @@ defmodule Volt.Plugin.Solid do @behaviour Volt.Plugin + alias Volt.JS.Runtime.PackageSet + @runtime_packages %{ - "@babel/standalone" => "^7.0.0", - "babel-preset-solid" => "^1.9.0" + "@babel/standalone" => "7.29.8", + "babel-preset-solid" => "1.9.12" } + @runtime_package_set PackageSet.bundled(:volt, :solid, @runtime_packages) @runtime_name __MODULE__.Runtime @jsx_exts ~w(.jsx .tsx) @@ -61,12 +64,13 @@ defmodule Volt.Plugin.Solid do def prebundle_entry(_specifier), do: nil def runtime_packages, do: @runtime_packages + def runtime_package_set, do: @runtime_package_set defp do_compile(source, filename, opts, plugin_opts) do runtime = - Volt.JS.Runtime.ensure!( + PackageSet.runtime_opts( + @runtime_package_set, name: @runtime_name, - packages: @runtime_packages, apis: [:browser, :node], entry: {:volt_asset, "compilers/solid.ts"}, bundle: true, @@ -75,6 +79,7 @@ defmodule Volt.Plugin.Solid do ], max_stack_size: 32 * 1024 * 1024 ) + |> Volt.JS.Runtime.ensure!() compile_options = filename diff --git a/lib/volt/plugin/svelte.ex b/lib/volt/plugin/svelte.ex index a5e8cbe..d3d5cb2 100644 --- a/lib/volt/plugin/svelte.ex +++ b/lib/volt/plugin/svelte.ex @@ -5,7 +5,10 @@ defmodule Volt.Plugin.Svelte do @behaviour Volt.Plugin - @runtime_packages %{"svelte" => "^5.0.0"} + alias Volt.JS.Runtime.PackageSet + + @runtime_packages %{"svelte" => "5.56.8"} + @runtime_package_set PackageSet.bundled(:volt, :svelte, @runtime_packages) @runtime_name __MODULE__.Runtime @resolve_extensions Volt.JS.Extensions.node_resolvable() @@ -37,7 +40,7 @@ defmodule Volt.Plugin.Svelte do def resolve(_, _), do: nil defp resolve_svelte(specifier, _importer) do - install = Volt.JS.Runtime.Installer.install!(@runtime_packages) + install = PackageSet.install!(@runtime_package_set) case NPM.Resolution.PackageResolver.resolve(specifier, install.install_dir, extensions: @resolve_extensions, @@ -77,17 +80,19 @@ defmodule Volt.Plugin.Svelte do end def runtime_packages, do: @runtime_packages + def runtime_package_set, do: @runtime_package_set defp do_compile(path, source, opts, plugin_opts) do runtime = - Volt.JS.Runtime.ensure!( + PackageSet.runtime_opts( + @runtime_package_set, name: @runtime_name, - packages: @runtime_packages, apis: [:browser, :node], entry: {:volt_asset, "compilers/svelte.ts"}, bundle: true, max_stack_size: 16 * 1024 * 1024 ) + |> Volt.JS.Runtime.ensure!() compile_options = path diff --git a/lib/volt/tailwind.ex b/lib/volt/tailwind.ex index 2339fbc..d162271 100644 --- a/lib/volt/tailwind.ex +++ b/lib/volt/tailwind.ex @@ -76,8 +76,8 @@ defmodule Volt.Tailwind do defp ensure_runtime(%{runtime: nil} = state) do runtime = - Volt.JS.Runtime.ensure!( - packages: Volt.Tailwind.Loader.runtime_packages(), + 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 -> @@ -88,6 +88,7 @@ defmodule Volt.Tailwind do end, entry: {:volt_asset, "compilers/tailwind.ts"} ) + |> Volt.JS.Runtime.ensure!() %{state | runtime: runtime, scanner: build_scanner(state.sources)} end diff --git a/lib/volt/tailwind/loader.ex b/lib/volt/tailwind/loader.ex index 7b211a1..2a8a723 100644 --- a/lib/volt/tailwind/loader.ex +++ b/lib/volt/tailwind/loader.ex @@ -1,16 +1,18 @@ defmodule Volt.Tailwind.Loader do @moduledoc "Handles Tailwind module loading, prebundling CJS graphs via OXC, and stylesheet resolution." + alias Volt.JS.Runtime.PackageSet alias Volt.JS.Transforms.Specifiers alias Volt.Tailwind.Resolver - @tailwind_install_spec "^4.3.0" @tailwind_runtime_deps %{ - "tailwindcss" => @tailwind_install_spec, - "@tailwindcss/typography" => "*" + "tailwindcss" => "4.3.3", + "@tailwindcss/typography" => "0.5.20" } + @runtime_package_set PackageSet.bundled(:volt, :tailwind, @tailwind_runtime_deps) def runtime_packages, do: @tailwind_runtime_deps + def runtime_package_set, do: @runtime_package_set def handlers(runtime_node_modules) do %{ diff --git a/priv/npm/solid.lock b/priv/npm/solid.lock new file mode 100644 index 0000000..97ce82f --- /dev/null +++ b/priv/npm/solid.lock @@ -0,0 +1,126 @@ +{ + "lockfileVersion": 1, + "packages": { + "@babel/helper-module-imports": { + "dependencies": { + "@babel/types": "^7.18.6" + }, + "has_install_script": false, + "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", + "version": "7.18.6" + }, + "@babel/helper-plugin-utils": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "version": "7.29.7" + }, + "@babel/helper-string-parser": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "version": "7.29.7" + }, + "@babel/helper-validator-identifier": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "version": "7.29.7" + }, + "@babel/plugin-syntax-jsx": { + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "has_install_script": false, + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "version": "7.29.7" + }, + "@babel/standalone": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-XgbPNz+u6JzB7cKGnPDoS1U24J5td8yp3HFWbT/6f4/ASZ0PqaQGIvts1o5v5AI+f6i3jdVB/L/o2CYLCv101A==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@babel/standalone/-/standalone-7.29.8.tgz", + "version": "7.29.8" + }, + "@babel/types": { + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "has_install_script": false, + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "version": "7.29.8" + }, + "babel-plugin-jsx-dom-expressions": { + "dependencies": { + "@babel/helper-module-imports": "7.18.6", + "@babel/plugin-syntax-jsx": "^7.18.6", + "@babel/types": "^7.20.7", + "html-entities": "2.3.3", + "parse5": "^7.1.2" + }, + "has_install_script": false, + "integrity": "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.40.7.tgz", + "version": "0.40.7" + }, + "babel-preset-solid": { + "dependencies": { + "babel-plugin-jsx-dom-expressions": "^0.40.6" + }, + "has_install_script": false, + "integrity": "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.12.tgz", + "version": "1.9.12" + }, + "entities": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "version": "6.0.1" + }, + "html-entities": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "version": "2.3.3" + }, + "parse5": { + "dependencies": { + "entities": "^6.0.0" + }, + "has_install_script": false, + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "version": "7.3.0" + } + }, + "policy": { + "allow_registry_redirects": false, + "allowed_registries": [ + "https://registry.npmjs.org" + ], + "block_exotic_subdeps": true, + "exotic_deps": [] + } +} diff --git a/priv/npm/svelte.lock b/priv/npm/svelte.lock new file mode 100644 index 0000000..78620ec --- /dev/null +++ b/priv/npm/svelte.lock @@ -0,0 +1,205 @@ +{ + "lockfileVersion": 1, + "packages": { + "@jridgewell/gen-mapping": { + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "has_install_script": false, + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "version": "0.3.13" + }, + "@jridgewell/remapping": { + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "has_install_script": false, + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "version": "2.3.5" + }, + "@jridgewell/resolve-uri": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "version": "3.1.2" + }, + "@jridgewell/sourcemap-codec": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "version": "1.5.5" + }, + "@jridgewell/trace-mapping": { + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + }, + "has_install_script": false, + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "version": "0.3.31" + }, + "@sveltejs/acorn-typescript": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-J1jNYG23QWd67UfrQSFHtjhV37r9mVi0gdc12A3MWPldOjRK35Xk+um+qACPVjgw3AleiqoyEAhok8Wm3q46NA==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.12.tgz", + "version": "1.0.12" + }, + "@types/estree": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "version": "1.0.9" + }, + "@types/trusted-types": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "version": "2.0.7" + }, + "acorn": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "version": "8.18.0" + }, + "aria-query": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "version": "5.3.1" + }, + "axobject-query": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "version": "4.1.0" + }, + "clsx": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "version": "2.1.1" + }, + "devalue": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", + "version": "5.9.0" + }, + "esm-env": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "version": "1.2.2" + }, + "esrap": { + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "has_install_script": false, + "integrity": "sha512-40GyiEJevYKXzYTHtZkFqAgTjLOuFcaXMao8TPyOlnWTlkHDlvZ6mPMJaJyOqVwrVCgomEG1WhJd81w0X+IcCw==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/esrap/-/esrap-2.3.2.tgz", + "version": "2.3.2" + }, + "is-reference": { + "dependencies": { + "@types/estree": "^1.0.6" + }, + "has_install_script": false, + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "version": "3.0.3" + }, + "locate-character": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "version": "3.0.0" + }, + "magic-string": { + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + }, + "has_install_script": false, + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "version": "0.30.21" + }, + "svelte": { + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "has_install_script": false, + "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", + "version": "5.56.8" + }, + "zimmerframe": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "version": "1.1.4" + } + }, + "policy": { + "allow_registry_redirects": false, + "allowed_registries": [ + "https://registry.npmjs.org" + ], + "block_exotic_subdeps": true, + "exotic_deps": [] + } +} diff --git a/priv/npm/tailwind.lock b/priv/npm/tailwind.lock new file mode 100644 index 0000000..0f67f46 --- /dev/null +++ b/priv/npm/tailwind.lock @@ -0,0 +1,58 @@ +{ + "lockfileVersion": 1, + "packages": { + "@tailwindcss/typography": { + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "has_install_script": false, + "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", + "version": "0.5.20" + }, + "cssesc": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "version": "3.0.0" + }, + "postcss-selector-parser": { + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "has_install_script": false, + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "version": "6.0.10" + }, + "tailwindcss": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "version": "4.3.3" + }, + "util-deprecate": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "version": "1.0.2" + } + }, + "policy": { + "allow_registry_redirects": false, + "allowed_registries": [ + "https://registry.npmjs.org" + ], + "block_exotic_subdeps": true, + "exotic_deps": [] + } +} diff --git a/scripts/update_runtime_locks.exs b/scripts/update_runtime_locks.exs new file mode 100644 index 0000000..bab34b3 --- /dev/null +++ b/scripts/update_runtime_locks.exs @@ -0,0 +1,62 @@ +Logger.configure(level: :warning) + +alias Volt.JS.Runtime.Installer + +sets = [ + Volt.Plugin.Svelte.runtime_package_set(), + Volt.Plugin.Solid.runtime_package_set(), + Volt.Tailwind.Loader.runtime_package_set() +] + +{opts, args} = OptionParser.parse!(System.argv(), strict: [only: :keep]) + +if args != [] do + raise ArgumentError, "unexpected arguments: #{Enum.join(args, " ")}" +end + +requested = opts |> Keyword.get_values(:only) |> MapSet.new() + +selected = + if MapSet.size(requested) == 0 do + sets + else + Enum.filter(sets, &MapSet.member?(requested, Atom.to_string(&1.name))) + end + +found = selected |> Enum.map(&Atom.to_string(&1.name)) |> MapSet.new() +missing = MapSet.difference(requested, found) + +if MapSet.size(missing) > 0 do + raise ArgumentError, + "unknown runtime package sets: #{missing |> Enum.sort() |> Enum.join(", ")}" +end + +tmp_dir = + Path.join( + System.tmp_dir!(), + "volt-runtime-locks-#{System.unique_integer([:positive])}" + ) + +File.mkdir_p!(tmp_dir) + +try do + Enum.each(selected, fn set -> + install = + Installer.install!(set.packages, + install_dir: Path.join(tmp_dir, Atom.to_string(set.name)), + force: true + ) + + generated = Path.join(install.install_dir, "npm.lock") + target = Path.expand("../priv/npm/#{set.name}.lock", __DIR__) + File.mkdir_p!(Path.dirname(target)) + File.cp!(generated, target) + + digest = + target |> File.read!() |> then(&:crypto.hash(:sha256, &1)) |> Base.encode16(case: :lower) + + Mix.shell().info("Updated #{Path.relative_to_cwd(target)} (sha256: #{digest})") + end) +after + File.rm_rf!(tmp_dir) +end diff --git a/test/volt/js/runtime/package_set_test.exs b/test/volt/js/runtime/package_set_test.exs new file mode 100644 index 0000000..39fe869 --- /dev/null +++ b/test/volt/js/runtime/package_set_test.exs @@ -0,0 +1,60 @@ +defmodule Volt.JS.Runtime.PackageSetTest do + use ExUnit.Case, async: true + + alias Volt.JS.Runtime.PackageSet + + @runtime_modules [Volt.Plugin.Svelte, Volt.Plugin.Solid, Volt.Tailwind.Loader] + + test "built-in package sets ship exact direct versions in valid npm_ex locks" do + Enum.each(@runtime_modules, fn module -> + set = module.runtime_package_set() + lockfile_path = PackageSet.lockfile_path(set) + + assert File.regular?(lockfile_path) + assert NPM.Lockfile.version(lockfile_path) == 1 + assert {:ok, lockfile} = NPM.Lockfile.read(lockfile_path) + assert {:ok, policy} = NPM.Lockfile.read_policy(lockfile_path) + assert NPM.Lockfile.policy_matches?(policy) + + Enum.each(set.packages, fn {package, exact_version} -> + assert %{version: ^exact_version} = Map.fetch!(lockfile, package) + end) + end) + end + + test "runtime opts cannot replace a package set's packages or lockfile" do + set = PackageSet.bundled(:volt, :fixture, %{"fixture" => "1.0.0"}) + + opts = + PackageSet.runtime_opts(set, + packages: %{"other" => "2.0.0"}, + lockfile: "/tmp/other.lock", + bundle: true + ) + + assert opts[:packages] == %{"fixture" => "1.0.0"} + assert opts[:lockfile] == PackageSet.lockfile_path(set) + assert opts[:bundle] + end + + @tag :integration + @tag :tmp_dir + test "built-in package sets install their packaged locks without re-resolving", %{ + tmp_dir: tmp_dir + } do + Enum.each(@runtime_modules, fn module -> + set = module.runtime_package_set() + source_lock = PackageSet.lockfile_path(set) + + install = + PackageSet.install!(set, + install_dir: Path.join(tmp_dir, Atom.to_string(set.name)), + force: true + ) + + installed_lock = Path.join(install.install_dir, "npm.lock") + + assert File.read!(installed_lock) == File.read!(source_lock) + end) + end +end diff --git a/test/volt/js/runtime_test.exs b/test/volt/js/runtime_test.exs index 7bf73d9..deac552 100644 --- a/test/volt/js/runtime_test.exs +++ b/test/volt/js/runtime_test.exs @@ -8,7 +8,12 @@ defmodule Volt.JS.RuntimeTest do tmp_dir = Path.join(System.tmp_dir!(), Atom.to_string(name)) on_exit(fn -> - if pid = GenServer.whereis(name), do: Runtime.stop(pid) + try do + if pid = GenServer.whereis(name), do: Runtime.stop(pid) + catch + :exit, _reason -> :ok + end + File.rm_rf!(tmp_dir) end) @@ -27,4 +32,34 @@ defmodule Volt.JS.RuntimeTest do Runtime.ensure!(name: name, packages: %{}, install_dir: second_dir) end end + + test "named runtimes include package-owned lock contents in their signature", %{ + name: name, + tmp_dir: tmp_dir + } do + install_dir = Path.join(tmp_dir, "runtime") + lockfile = Path.join(tmp_dir, "npm.lock") + File.mkdir_p!(tmp_dir) + :ok = NPM.Lockfile.write(%{}, lockfile) + + runtime = + Runtime.ensure!( + name: name, + packages: %{}, + lockfile: lockfile, + install_dir: install_dir + ) + + assert is_pid(runtime.pid) + File.write!(lockfile, File.read!(lockfile) <> "\n") + + assert_raise ArgumentError, ~r/already started with different options/, fn -> + Runtime.ensure!( + name: name, + packages: %{}, + lockfile: lockfile, + install_dir: install_dir + ) + end + end end From 84ffe3c1016fc74277276785393ed97296ef29e4 Mon Sep 17 00:00:00 2001 From: sergeivaskov Date: Tue, 11 Aug 2026 14:40:15 +0300 Subject: [PATCH 030/128] fix: honor configured npm registry policy --- CHANGELOG.md | 1 + lib/volt/js/runtime/package_set.ex | 18 +++++++++++++++--- test/volt/js/runtime/package_set_test.exs | 23 ++++++++++++++++++++++- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f368c3b..7183815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Install built-in Svelte, Solid, and Tailwind runtimes from package-owned npm_ex lockfiles, and include each lock digest in the named runtime signature. +- Preserve private and corporate npm registry compatibility by resolving exact built-in runtime packages through the configured registry when a packaged lock policy is incompatible. ## 0.17.10 - 2026-07-17 diff --git a/lib/volt/js/runtime/package_set.ex b/lib/volt/js/runtime/package_set.ex index ad690d9..6e17418 100644 --- a/lib/volt/js/runtime/package_set.ex +++ b/lib/volt/js/runtime/package_set.ex @@ -36,8 +36,20 @@ defmodule Volt.JS.Runtime.PackageSet do end defp lock_opts(set, opts) do - opts - |> Keyword.delete(:packages) - |> Keyword.put(:lockfile, lockfile_path(set)) + lockfile = lockfile_path(set) + opts = opts |> Keyword.delete(:packages) |> Keyword.delete(:lockfile) + + if use_bundled_lock?(lockfile) do + Keyword.put(opts, :lockfile, lockfile) + else + opts + end + end + + defp use_bundled_lock?(lockfile) do + case NPM.Lockfile.read_policy(lockfile) do + {:ok, policy} when is_map(policy) -> NPM.Lockfile.policy_matches?(policy) + _ -> true + end end end diff --git a/test/volt/js/runtime/package_set_test.exs b/test/volt/js/runtime/package_set_test.exs index 39fe869..38d29c4 100644 --- a/test/volt/js/runtime/package_set_test.exs +++ b/test/volt/js/runtime/package_set_test.exs @@ -1,5 +1,5 @@ defmodule Volt.JS.Runtime.PackageSetTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false alias Volt.JS.Runtime.PackageSet @@ -37,6 +37,27 @@ defmodule Volt.JS.Runtime.PackageSetTest do assert opts[:bundle] end + test "built-in package sets use configured resolution when the bundled lock policy is incompatible" do + registry = "https://npm.example.test" + env = ~w(NPM_REGISTRY NPM_MIRROR NPM_EX_ALLOWED_REGISTRIES) + previous = Map.new(env, &{&1, System.get_env(&1)}) + + Enum.each(env, &System.put_env(&1, registry)) + + on_exit(fn -> + Enum.each(previous, fn + {name, nil} -> System.delete_env(name) + {name, value} -> System.put_env(name, value) + end) + end) + + set = Volt.Plugin.Svelte.runtime_package_set() + opts = PackageSet.runtime_opts(set) + + assert opts[:packages] == %{"svelte" => "5.56.8"} + refute Keyword.has_key?(opts, :lockfile) + end + @tag :integration @tag :tmp_dir test "built-in package sets install their packaged locks without re-resolving", %{ From dd4282b53a268bbfae881a9439f84c5daeb85cfc Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 14 Aug 2026 23:34:38 +0200 Subject: [PATCH 031/128] Classify builder naming in Reach policy --- .reach.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/.reach.exs b/.reach.exs index 327aabb..d6205ba 100644 --- a/.reach.exs +++ b/.reach.exs @@ -65,6 +65,7 @@ model = [ logic = [ "Volt.Assets", "Volt.Assets.Query", + "Volt.Builder.Naming", "Volt.Builder.Resolver", "Volt.ChunkGraph", "Volt.CSS.AST", From aea5d5ab0d03e6f3824cc0e767f28e75f3875984 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 14 Aug 2026 23:34:38 +0200 Subject: [PATCH 032/128] Update Windows-enabled dependency stack --- CHANGELOG.md | 3 ++- guides/features/testing.md | 2 +- lib/volt/test/browser_runner.ex | 2 +- mix.exs | 10 +++++----- mix.lock | 20 ++++++++++---------- package.json | 2 +- scripts/postinstall.mjs | 14 ++++++++++++++ test/volt/integration/hmr_test.exs | 2 +- 8 files changed, 35 insertions(+), 20 deletions(-) create mode 100644 scripts/postinstall.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 57c4d4a..e5d8326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,9 @@ - Generate filesystem-safe entry and module labels for virtual modules on Windows. - Keep entry and module labels unique when filesystem sanitization produces collisions. - Use platform-aware absolute paths for builder, test discovery, glob transforms, and Tailwind resolution. -- Make type-aware lint test executables portable across Unix and Windows. +- Make type-aware lint test executables and development dependency installation portable across Unix and Windows. - Update GlobEx for Windows-rooted glob matching. +- Update QuickBEAM, OXC, Vize, Oxide, npm_ex, and ZiglerPrecompiled to their Windows-enabled releases. ## 0.17.10 - 2026-07-17 diff --git a/guides/features/testing.md b/guides/features/testing.md index 7775c54..38d96ef 100644 --- a/guides/features/testing.md +++ b/guides/features/testing.md @@ -309,7 +309,7 @@ npm install --save-dev playwright npx playwright install chromium ``` -By default Volt uses `node_modules/.bin/playwright` when present, otherwise `playwright` from `PATH`. Pass PlaywrightEx supervisor options with `playwright: [...]` if your executable lives elsewhere. +By default Volt uses `node_modules/playwright/cli.js` when present, otherwise `playwright` from `PATH`. Pass PlaywrightEx supervisor options with `playwright: [...]` if your executable lives elsewhere. ## ExUnit integration diff --git a/lib/volt/test/browser_runner.ex b/lib/volt/test/browser_runner.ex index dd73283..1ad445b 100644 --- a/lib/volt/test/browser_runner.ex +++ b/lib/volt/test/browser_runner.ex @@ -73,7 +73,7 @@ defmodule Volt.Test.BrowserRunner do end defp playwright_executable do - local = Path.expand("node_modules/.bin/playwright") + local = Path.expand("node_modules/playwright/cli.js") if File.exists?(local), do: local, else: "playwright" end diff --git a/mix.exs b/mix.exs index 07d6145..dc44d51 100644 --- a/mix.exs +++ b/mix.exs @@ -35,10 +35,10 @@ defmodule Volt.MixProject do [ {:reach, "~> 2.6.1", only: [:dev, :test], runtime: false}, {:glob_ex, "~> 0.1.12"}, - {:oxc, "~> 0.17.7"}, - {:vize, "~> 0.14.0"}, - {:oxide_ex, "~> 0.2.1"}, - {:quickbeam, "~> 0.10.20"}, + {:oxc, "~> 0.17.8"}, + {:vize, "~> 0.14.1"}, + {:oxide_ex, "~> 0.2.2"}, + {:quickbeam, "~> 0.11.0"}, {:dotenvy, "~> 1.1"}, {:floki, "~> 0.38"}, {:plug, "~> 1.16"}, @@ -48,7 +48,7 @@ defmodule Volt.MixProject do {:jason, "~> 1.4"}, {:json_codec, "~> 0.2.3"}, {:igniter, "~> 0.5", optional: true}, - {:npm, "~> 0.7.5"}, + {:npm, "~> 0.7.6"}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:ex_slop, "~> 0.4", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index 42468bd..992b934 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,7 @@ %{ "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"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "castore": {:hex, :castore, "1.0.20", "455e48f7115eca98c9f2b0e7a152b5a2e8f2a8a4f964c96e95bd31645ee5fa59", [:mix], [], "hexpm", "940eafbfd8b14bee649f083bc11b3b54ec555b54c3e4ea8213351ff6fee39c10"}, + "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"}, "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, @@ -32,26 +32,26 @@ "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_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_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"}, "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.5", "d8680384b4116a29270cd1fb706da017d1be3870d7d2993de382140d7b98d378", [: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", "174f6b230669f08240a87bef76f3d9ed03e03c126fea6ea4d1a3011a1681a95f"}, + "npm": {:hex, :npm, "0.7.6", "de8ca3dd26a40fac7ab8a17680088b8d55bab0e0294be9770b029c276361482f", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:npm_semver, "~> 0.1.0", [hex: :npm_semver, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "e0a1ce77e3e49ec108e095aba133bd315e86da298dba26b142a105972141d9ef"}, "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, - "oxc": {:hex, :oxc, "0.17.7", "2964e56c3682fa16e68732e0ad8ccec92f667efc643b39147b5f017b9771e885", [: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", "246d3d45925a08bbea4681ffb38a1935df14d73ec1929669957822c77f447e2c"}, - "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"}, "pegasus": {:hex, :pegasus, "0.2.6", "b4af6522326fbb2ffd1bb706e78ec05854fbcb4b03a7fe08f8db2e9de1d0be67", [:mix], [{:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0ac159f0ccab7967cf90208327cc8a35788874814c8d78e19d47104d3fc049b9"}, "pi_bridge": {:hex, :pi_bridge, "0.6.21", "f1a63da5f49fef4f6b35e98edd5400cbd64a49fb8bda84be41b7e91f3deb0b89", [:mix], [{:bandit, "~> 1.8", [hex: :bandit, repo: "hexpm", optional: false]}, {:dune, "~> 0.3", [hex: :dune, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_ast, "~> 0.12", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.1.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:quackdb, "~> 0.5.4", [hex: :quackdb, repo: "hexpm", optional: false]}, {:reach, "~> 2.6", [hex: :reach, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:req_llm, "~> 1.6", [hex: :req_llm, repo: "hexpm", optional: true]}], "hexpm", "b226fb60965a8e928186af3c3b96fe8f0fefd8c6cc7be679d6e169ba84105de9"}, "playwright_ex": {:hex, :playwright_ex, "0.7.1", "4a8a3f317734a5d20c4f25b5b425d565d05730166013f86f0c1a799714e61e2d", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:websockex, "~> 0.4", [hex: :websockex, repo: "hexpm", optional: true]}], "hexpm", "558176309cf322b8ba5c4f85ec6c655c1667cd68531db83d1d3065bd572755c2"}, "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.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "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.10.20", "286874038d8c409ca9eb9eb63fd074fb44a474e8f68ddb53f7b6cf42611a4036", [: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.5", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.17.2", [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", "211bf1f22de6dc4def9ae07af529904ff49f701740508331b7e7673220640663"}, + "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"}, "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.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"}, + "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"}, "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"}, @@ -61,11 +61,11 @@ "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, "varint": {:hex, :varint, "1.6.0", "7bced828599b2eb84491a9f067f8a5a67fc35a946d3b7582278083c7fd434b00", [:mix], [], "hexpm", "2b4f4a20650aeebe993a70b03bb99571bcbddc27c361322c28b586d8a772ce4e"}, - "vize": {:hex, :vize, "0.14.0", "af336da6947e9e7ac05f3f830550ab2314847e1101334577621cd3e1753f43ae", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "661e6209f74ae7b7c3cc06ca32833218022d5b46f29ee1a9c93e871502642a6e"}, + "vize": {:hex, :vize, "0.14.1", "faedc46abc42c96d7cd5480b93e7f3626e51d1f22d9927c4302e5a3390c48ba5", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "c1b2851a6855093aca8bc8f22a358a4e8bb1965ae98e01c08c9f45c8fac8a334"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "zig_get": {:hex, :zig_get, "0.15.2", "a6ccaa894213839ba95615bf9be2b2c9268e37ab9547a2344830202cfd6d7cc0", [:mix], [], "hexpm", "e6b0028f2d5a8da791ff8037deff5b492784017d8d241e598377a24bd765f56f"}, "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.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.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"}, } diff --git a/package.json b/package.json index 49eba5a..27a24b0 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,6 @@ "playwright": "1.61.1" }, "scripts": { - "postinstall": "chmod +x node_modules/@oxlint-tsgolint/*/tsgolint 2>/dev/null || true" + "postinstall": "node scripts/postinstall.mjs" } } diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs new file mode 100644 index 0000000..c3d3707 --- /dev/null +++ b/scripts/postinstall.mjs @@ -0,0 +1,14 @@ +import { chmodSync, existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +if (process.platform !== "win32") { + const packageRoot = "node_modules/@oxlint-tsgolint"; + + for (const directory of readdirSync(packageRoot)) { + const executable = join(packageRoot, directory, "tsgolint"); + + if (existsSync(executable)) { + chmodSync(executable, 0o755); + } + } +} diff --git a/test/volt/integration/hmr_test.exs b/test/volt/integration/hmr_test.exs index 53a0e10..e42e7b7 100644 --- a/test/volt/integration/hmr_test.exs +++ b/test/volt/integration/hmr_test.exs @@ -87,7 +87,7 @@ defmodule Volt.Integration.HMRTest do end defp playwright_executable do - local = Path.expand("node_modules/.bin/playwright") + local = Path.expand("node_modules/playwright/cli.js") if File.exists?(local), do: local, else: "playwright" end From cff078ede1a6d1ad26d0c5da8fd09d569b5671d7 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 14 Aug 2026 23:39:16 +0200 Subject: [PATCH 033/128] Update Bandit past WebSocket advisory --- CHANGELOG.md | 1 + mix.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5d8326..46efb76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Make type-aware lint test executables and development dependency installation portable across Unix and Windows. - Update GlobEx for Windows-rooted glob matching. - Update QuickBEAM, OXC, Vize, Oxide, npm_ex, and ZiglerPrecompiled to their Windows-enabled releases. +- Update Bandit to resolve its fragmented WebSocket message denial-of-service advisory. ## 0.17.10 - 2026-07-17 diff --git a/mix.lock b/mix.lock index 992b934..9d8eef3 100644 --- a/mix.lock +++ b/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.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"}, "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"}, From b12f01486a6766630ee70137db236dec66289e57 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 15 Aug 2026 08:44:40 +0200 Subject: [PATCH 034/128] Classify runtime package sets in Reach policy --- .reach.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/.reach.exs b/.reach.exs index d6205ba..6dc3806 100644 --- a/.reach.exs +++ b/.reach.exs @@ -133,6 +133,7 @@ infrastructure = [ "Volt.JS.Runtime.Entry", "Volt.JS.Runtime.Error", "Volt.JS.Runtime.Installer", + "Volt.JS.Runtime.PackageSet", "Volt.Tailwind.Loader" ] From 3380315a5656f0fb98749c747269261811685106 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 23 Aug 2026 15:05:49 +0200 Subject: [PATCH 035/128] Keep test durations independent of mocked clocks --- priv/ts/test/runner.ts | 10 ++++++---- test/volt/test/runner_test.exs | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/priv/ts/test/runner.ts b/priv/ts/test/runner.ts index fa23913..508943b 100644 --- a/priv/ts/test/runner.ts +++ b/priv/ts/test/runner.ts @@ -2,6 +2,8 @@ import { SkipError, serializeError } from './errors' import { expect } from './matchers' import { reset, state } from './state' +const now = Date.now.bind(Date) + export async function __voltCollectTestModule(code: string, file: string) { loadTestModule(code, file) @@ -32,12 +34,12 @@ export async function __voltRunLoadedTestModule( async function runLoadedTests(file: string, onlyId?: number) { const results: Volt.Test.Result[] = [] - const startedAt = Date.now() + const startedAt = now() const tests = onlyId === undefined ? state.tests : state.tests.filter((test) => test.id === onlyId) for (const registered of tests) { - const testStartedAt = Date.now() + const testStartedAt = now() if (registered.mode !== 'run') { results.push(result(registered, 'skipped', testStartedAt, undefined, registered.skipReason)) @@ -65,7 +67,7 @@ async function runLoadedTests(file: string, onlyId?: number) { return { file, status: failed === 0 ? 'passed' : 'failed', - duration: Date.now() - startedAt, + duration: now() - startedAt, total: results.length, failed, skipped, @@ -127,7 +129,7 @@ function result( name: registered.name, fullName: fullName(registered), status, - duration: Date.now() - startedAt, + duration: now() - startedAt, ...(error ? { error } : {}), ...(skipReason ? { skipReason } : {}) } diff --git a/test/volt/test/runner_test.exs b/test/volt/test/runner_test.exs index abc25e2..a6e3d5b 100644 --- a/test/volt/test/runner_test.exs +++ b/test/volt/test/runner_test.exs @@ -92,6 +92,21 @@ defmodule Volt.Test.RunnerTest do assert test.status == :passed end + test "keeps durations monotonic when a test replaces Date.now", %{tmp_dir: tmp_dir} do + path = + write!(tmp_dir, "fake_clock.test.js", ~JS""" + test('replaces the wall clock', () => { + Date.now = () => 0 + }) + """) + + assert {:ok, %Result{duration: duration, tests: [%Result.Test{duration: test_duration}]}} = + Runner.run_file(path) + + assert duration >= 0 + assert test_duration >= 0 + end + test "loads setup files before the test module", %{tmp_dir: tmp_dir} do setup_path = write!(tmp_dir, "setup.ts", ~TS""" From 379f59e11f604c535ec173d8395f32dde0538000 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 24 Aug 2026 18:21:06 +0200 Subject: [PATCH 036/128] Integrate Vize SFC bundler metadata --- lib/volt/plugin/vue.ex | 71 ++++++++++++++++++++++++------------- mix.exs | 2 +- mix.lock | 2 +- test/volt/pipeline_test.exs | 32 +++++++++++++++++ 4 files changed, 81 insertions(+), 26 deletions(-) diff --git a/lib/volt/plugin/vue.ex b/lib/volt/plugin/vue.ex index 6aa5872..2abfaca 100644 --- a/lib/volt/plugin/vue.ex +++ b/lib/volt/plugin/vue.ex @@ -23,28 +23,30 @@ defmodule Volt.Plugin.Vue do if query == "" and Path.extname(base_path) == ".vue" do sfc_opts = [ - filename: Path.basename(path), + filename: base_path, vapor: Keyword.get(opts, :vapor, false), strip_types: true, - custom_renderer: Keyword.get(opts, :custom_renderer, false) + custom_renderer: Keyword.get(opts, :custom_renderer, false), + source_map: Keyword.get(opts, :sourcemap, true) ] - case Vize.compile_sfc(source, sfc_opts) do - {:ok, result} -> - {:ok, - %Volt.Pipeline.Result{ - code: maybe_append_style_imports(result.code, path, source, opts), - sourcemap: nil, - css: result.css, - hashes: %Volt.Pipeline.Result.Hashes{ - template: result.template_hash, - style: result.style_hash, - script: result.script_hash - } - }} - - {:error, reason} -> - {:error, reason} + with {:ok, assets} <- Vize.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) + |> append_template_asset_imports(assets) + |> maybe_append_style_imports(path, source, opts), + sourcemap: result.source_map, + css: result.css, + hashes: %Volt.Pipeline.Result.Hashes{ + template: result.template_hash, + style: result.style_hash, + script: result.script_hash + } + }} end end end @@ -52,11 +54,13 @@ defmodule Volt.Plugin.Vue do @impl true def extract_imports(path, source, _opts) do if Path.extname(path) == ".vue" do - {:ok, - %Volt.JS.ImportExtractor.Result{ - imports: source |> imports() |> Enum.map(&{:static, &1}), - workers: [] - }} + with {:ok, assets} <- Vize.SFC.collect_template_assets(source, filename: path) do + {:ok, + %Volt.JS.ImportExtractor.Result{ + imports: source |> imports(assets) |> Enum.map(&{:static, &1}), + workers: [] + }} + end end end @@ -84,6 +88,20 @@ defmodule Volt.Plugin.Vue do end end + defp append_template_asset_imports(code, assets) do + imports = Enum.map(assets, &template_asset_import/1) + IO.iodata_to_binary([code | imports]) + end + + defp template_asset_import(asset) do + "import $name from \"__specifier__\";" + |> OXC.parse!("vue-template-asset-import.ts") + |> OXC.bind(name: asset.binding) + |> Volt.JS.AST.replace_literal("__specifier__", asset_specifier(asset)) + |> OXC.codegen!() + |> then(&["\n", String.trim(&1)]) + end + defp style_import(path, module) do specifier = Volt.Plugin.EmbeddedModule.specifier(path, module) @@ -94,7 +112,12 @@ defmodule Volt.Plugin.Vue do |> then(&["\n", String.trim(&1)]) end - defp imports(source), do: source |> scripts() |> Enum.flat_map(&script_imports/1) + defp imports(source, assets) do + script_imports = source |> scripts() |> Enum.flat_map(&script_imports/1) + script_imports ++ Enum.map(assets, &asset_specifier/1) + end + + defp asset_specifier(asset), do: asset.url |> String.split("#", parts: 2) |> hd() defp scripts(source) do case Vize.parse_sfc(source) do diff --git a/mix.exs b/mix.exs index dc44d51..572829a 100644 --- a/mix.exs +++ b/mix.exs @@ -36,7 +36,7 @@ defmodule Volt.MixProject do {:reach, "~> 2.6.1", only: [:dev, :test], runtime: false}, {:glob_ex, "~> 0.1.12"}, {:oxc, "~> 0.17.8"}, - {:vize, "~> 0.14.1"}, + {:vize, "~> 0.14.2"}, {:oxide_ex, "~> 0.2.2"}, {:quickbeam, "~> 0.11.0"}, {:dotenvy, "~> 1.1"}, diff --git a/mix.lock b/mix.lock index 9d8eef3..affe00f 100644 --- a/mix.lock +++ b/mix.lock @@ -61,7 +61,7 @@ "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, "varint": {:hex, :varint, "1.6.0", "7bced828599b2eb84491a9f067f8a5a67fc35a946d3b7582278083c7fd434b00", [:mix], [], "hexpm", "2b4f4a20650aeebe993a70b03bb99571bcbddc27c361322c28b586d8a772ce4e"}, - "vize": {:hex, :vize, "0.14.1", "faedc46abc42c96d7cd5480b93e7f3626e51d1f22d9927c4302e5a3390c48ba5", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "c1b2851a6855093aca8bc8f22a358a4e8bb1965ae98e01c08c9f45c8fac8a334"}, + "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"}, "zig_get": {:hex, :zig_get, "0.15.2", "a6ccaa894213839ba95615bf9be2b2c9268e37ab9547a2344830202cfd6d7cc0", [:mix], [], "hexpm", "e6b0028f2d5a8da791ff8037deff5b492784017d8d241e598377a24bd765f56f"}, diff --git a/test/volt/pipeline_test.exs b/test/volt/pipeline_test.exs index fc95a01..cf63ee3 100644 --- a/test/volt/pipeline_test.exs +++ b/test/volt/pipeline_test.exs @@ -152,6 +152,38 @@ defmodule Volt.PipelineTest do {:ok, result} = Volt.Pipeline.compile("App.vue", source) assert is_binary(result.css) end + + test "returns authored SFC source maps when enabled" do + source = """ + + + """ + + {:ok, mapped} = Volt.Pipeline.compile("src/App.vue", source, sourcemap: true) + assert is_binary(mapped.sourcemap) + assert mapped.sourcemap =~ "src/App.vue" + + {:ok, unmapped} = Volt.Pipeline.compile("src/App.vue", source, sourcemap: false) + assert unmapped.sourcemap == nil + end + + test "rewrites template assets to imports and reports their dependencies" do + source = """ + + """ + + {:ok, result} = Volt.Pipeline.compile("src/App.vue", source) + + assert result.code =~ ~s(import _imports_0 from "./logo.png") + assert result.code =~ ~s(import _imports_1 from "@/icons.svg") + assert result.code =~ ~s(_imports_1 + "#check") + + assert {:ok, imports} = Volt.Plugin.Vue.extract_imports("src/App.vue", source, []) + assert {:static, "./logo.png"} in imports.imports + assert {:static, "@/icons.svg"} in imports.imports + end end describe "compile/3 with Svelte" do From b7cf7d155647e107aa2c5622807497885ab3b7d1 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 4 Sep 2026 18:28:54 +0300 Subject: [PATCH 037/128] feat(hmr): support ignored watcher paths - Add configurable GlobEx exclusions across server, watcher, and CLI entry points - Normalize macOS filesystem alias events before HMR routing - Ignore dependency, build, VCS, and test output directories by default --- .reach.exs | 4 +- CHANGELOG.md | 8 +++ guides/cheatsheets/configuration.cheatmd | 8 +++ guides/features/hmr.md | 17 ++++++ lib/mix/tasks/volt.dev.ex | 14 +++++ lib/volt/config/server.ex | 1 + lib/volt/dev_server.ex | 1 + lib/volt/watcher.ex | 26 ++++++-- lib/volt/watcher/ignore.ex | 31 ++++++++++ lib/volt/watcher/path.ex | 56 ++++++++++++++++++ mix.exs | 2 +- test/volt/config_test.exs | 6 ++ test/volt/watcher/path_test.exs | 19 ++++++ test/volt/watcher_test.exs | 75 ++++++++++++++++++++++++ 14 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 lib/volt/watcher/ignore.ex create mode 100644 lib/volt/watcher/path.ex create mode 100644 test/volt/watcher/path_test.exs diff --git a/.reach.exs b/.reach.exs index 6dc3806..3008122 100644 --- a/.reach.exs +++ b/.reach.exs @@ -33,7 +33,9 @@ orchestrator = [ "Volt.JS.Vendor", "Volt.Pipeline", "Volt.Tailwind", - "Volt.Watcher" + "Volt.Watcher", + "Volt.Watcher.Ignore", + "Volt.Watcher.Path" ] model = [ diff --git a/CHANGELOG.md b/CHANGELOG.md index fd17363..0c677f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,16 @@ ## Unreleased +## 0.17.11 - 2026-09-04 + +### Added + +- Add Vite-style watcher ignore patterns through `config :volt, :server`, `Volt.Watcher`, and `mix volt.dev`, with default exclusions for common generated and dependency directories. + ### Fixed +- Normalize filesystem aliases before routing watcher events so macOS events retain root-relative HMR paths. + - Generate filesystem-safe entry and module labels for virtual modules on Windows. - Keep entry and module labels unique when filesystem sanitization produces collisions. - Use platform-aware absolute paths for builder, test discovery, glob transforms, and Tailwind resolution. diff --git a/guides/cheatsheets/configuration.cheatmd b/guides/cheatsheets/configuration.cheatmd index 1393be6..00ca691 100644 --- a/guides/cheatsheets/configuration.cheatmd +++ b/guides/cheatsheets/configuration.cheatmd @@ -240,6 +240,14 @@ config :volt, :server, watch_dirs: ["lib/", "assets/"] Additional directories to watch for Tailwind rebuilds. The asset root is watched automatically. +### Watch Ignored + +```elixir +config :volt, :server, watch_ignored: ["**/.generated/**"] +``` + +GlobEx patterns excluded from watcher events. Patterns are relative to each watched root. + ### Automatic Watcher ```elixir diff --git a/guides/features/hmr.md b/guides/features/hmr.md index b02c790..b94cbd7 100644 --- a/guides/features/hmr.md +++ b/guides/features/hmr.md @@ -58,6 +58,23 @@ mix volt.dev --reload-dir content --reload-dir layouts This is intentionally generic: Volt does not parse those files or assign site semantics to them. +## Ignoring watcher paths + +Exclude generated or otherwise irrelevant files before Volt schedules compilation or HMR work: + +```elixir +config :volt, :server, + watch_ignored: ["**/.generated/**", "vendor/cache/**"] +``` + +Patterns use GlobEx syntax and are resolved relative to each watched root. The same option is available from the CLI: + +```bash +mix volt.dev --watch-ignore "**/.generated/**" +``` + +Volt ignores `.git`, `node_modules`, `test-results`, `_build`, and `deps` directories by default. Ignoring a path affects file watching only; it does not prevent the dev server from serving an explicitly requested module. + ## `import.meta.hot` Each module served in dev mode includes an `import.meta.hot` object for granular HMR: diff --git a/lib/mix/tasks/volt.dev.ex b/lib/mix/tasks/volt.dev.ex index b66a777..fb9e219 100644 --- a/lib/mix/tasks/volt.dev.ex +++ b/lib/mix/tasks/volt.dev.ex @@ -15,6 +15,7 @@ defmodule Mix.Tasks.Volt.Dev do * `--root` — asset source directory (default from config or `"assets"`) * `--watch-dir` — additional directory to watch for Tailwind scanning (repeatable) * `--reload-dir` — additional directory whose changes trigger a full browser reload (repeatable) + * `--watch-ignore` — path or glob pattern excluded from watcher events (repeatable) * `--tailwind` — enable Tailwind CSS rebuilds * `--tailwind-css` — custom Tailwind input CSS file * `--tailwind-outdir` — directory to write rebuilt CSS (default: `"priv/static/assets/css"`) @@ -35,6 +36,7 @@ defmodule Mix.Tasks.Volt.Dev do root: :string, watch_dir: [:string, :keep], reload_dir: [:string, :keep], + watch_ignore: [:string, :keep], tailwind: :boolean, tailwind_css: :string, tailwind_outdir: :string, @@ -56,6 +58,7 @@ defmodule Mix.Tasks.Volt.Dev do cli_watch_dirs = Keyword.get_values(parsed, :watch_dir) cli_reload_dirs = Keyword.get_values(parsed, :reload_dir) + cli_watch_ignored = Keyword.get_values(parsed, :watch_ignore) watch_dirs = case cli_watch_dirs do @@ -69,6 +72,12 @@ defmodule Mix.Tasks.Volt.Dev do list -> list end + watch_ignored = + case cli_watch_ignored do + [] -> server_config.watch_ignored + list -> list + end + watch_dirs = if tailwind? and watch_dirs == [], do: [Paths.lib()], else: watch_dirs tailwind_css = Keyword.get(parsed, :tailwind_css) || tailwind_config[:css] @@ -81,6 +90,7 @@ defmodule Mix.Tasks.Volt.Dev do root: root, watch_dirs: watch_dirs, reload_dirs: reload_dirs, + watch_ignored: watch_ignored, tailwind: tailwind?, tailwind_css: tailwind_css, tailwind_outdir: Keyword.get(parsed, :tailwind_outdir, Paths.static_css()), @@ -99,6 +109,10 @@ defmodule Mix.Tasks.Volt.Dev do Mix.shell().info("[Volt] Full reload dirs: #{Enum.join(reload_dirs, ", ")}") end + if watch_ignored != [] do + Mix.shell().info("[Volt] Ignored watcher paths: #{Enum.join(watch_ignored, ", ")}") + end + unless iex_running?() do Process.sleep(:infinity) end diff --git a/lib/volt/config/server.ex b/lib/volt/config/server.ex index f11402c..7771734 100644 --- a/lib/volt/config/server.ex +++ b/lib/volt/config/server.ex @@ -4,6 +4,7 @@ defmodule Volt.Config.Server do defstruct prefix: Volt.Paths.prefix(), watch_dirs: [], reload_dirs: [], + watch_ignored: [], hmr_timeout: 60_000, watch: true end diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index 0097f42..0a2fbd2 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -70,6 +70,7 @@ defmodule Volt.DevServer do root: expanded_root, watch_dirs: watch_dirs, reload_dirs: server_config.reload_dirs, + watch_ignored: server_config.watch_ignored, tailwind: tailwind_config != [], tailwind_css: tailwind_config[:css], tailwind_outdir: Path.join(to_string(config.outdir), "css"), diff --git a/lib/volt/watcher.ex b/lib/volt/watcher.ex index 8a57dcd..aafdcf9 100644 --- a/lib/volt/watcher.ex +++ b/lib/volt/watcher.ex @@ -19,6 +19,8 @@ defmodule Volt.Watcher do (e.g. `["lib/"]` for `.ex`/`.heex` templates) * `:reload_dirs` — additional directories whose changes trigger a full browser reload without being compiled by Volt + * `:watch_ignored` — paths or glob patterns excluded from watcher events + (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) * `:target` — JS downlevel target @@ -49,7 +51,8 @@ defmodule Volt.Watcher do tailwind_full?: false, tailwind_outdir: nil, tailwind_dirs: [], - reload_dirs: [] + reload_dirs: [], + watch_ignored: [] ] def start_link(opts) do @@ -68,10 +71,18 @@ defmodule Volt.Watcher do config = opts - |> Keyword.drop([:root, :name, :watch_dirs, :reload_dirs, :tailwind_outdir]) + |> Keyword.drop([ + :root, + :name, + :watch_dirs, + :reload_dirs, + :watch_ignored, + :tailwind_outdir + ]) |> Map.new() all_dirs = Enum.uniq([root | watch_dirs ++ reload_dirs ++ tailwind_colocated_dirs(config)]) + watch_ignored = Volt.Watcher.Ignore.compile(Keyword.get(opts, :watch_ignored, []), all_dirs) fs_pids = Enum.map(all_dirs, fn dir -> @@ -86,7 +97,8 @@ defmodule Volt.Watcher do config: config, tailwind_outdir: tailwind_outdir, tailwind_dirs: all_dirs, - reload_dirs: reload_dirs + reload_dirs: reload_dirs, + watch_ignored: watch_ignored } if config[:tailwind] do @@ -131,7 +143,9 @@ defmodule Volt.Watcher do @impl true def handle_info({:file_event, _pid, {path, events}}, state) do - if relevant_write_event?(events) do + path = Volt.Watcher.Path.normalize_from_roots(path, state.tailwind_dirs) + + if relevant_write_event?(events) and not ignored_path?(path, state) do ext = Path.extname(path) cond do @@ -193,6 +207,10 @@ defmodule Volt.Watcher do Enum.any?(@write_events, &(&1 in events)) end + defp ignored_path?(path, state) do + Enum.any?(state.watch_ignored, &GlobEx.match?(&1, path)) + end + defp schedule_rebuild(state, path) do case Map.get(state.pending, path) do nil -> :ok diff --git a/lib/volt/watcher/ignore.ex b/lib/volt/watcher/ignore.ex new file mode 100644 index 0000000..717ab53 --- /dev/null +++ b/lib/volt/watcher/ignore.ex @@ -0,0 +1,31 @@ +defmodule Volt.Watcher.Ignore do + @moduledoc false + + @default_patterns [ + "**/.git/**", + "**/node_modules/**", + "**/test-results/**", + "**/_build/**", + "**/deps/**" + ] + + @spec compile([String.t()], [String.t()]) :: [GlobEx.t()] + def compile(patterns, roots) do + (@default_patterns ++ patterns) + |> Enum.uniq() + |> Enum.flat_map(&compile_pattern(&1, roots)) + end + + defp compile_pattern(pattern, roots) do + case Path.type(pattern) do + :relative -> Enum.map(roots, &compile_relative(pattern, &1)) + _absolute -> [GlobEx.compile!(pattern, match_dot: true)] + end + end + + defp compile_relative(pattern, root) do + root + |> Path.join(pattern) + |> GlobEx.compile!(match_dot: true) + end +end diff --git a/lib/volt/watcher/path.ex b/lib/volt/watcher/path.ex new file mode 100644 index 0000000..0fd9225 --- /dev/null +++ b/lib/volt/watcher/path.ex @@ -0,0 +1,56 @@ +defmodule Volt.Watcher.Path do + @moduledoc false + + @spec normalize_from_roots(String.t(), [String.t()]) :: String.t() + def normalize_from_roots(path, roots) do + path = Path.expand(path) + + Enum.find_value(roots, path, fn root -> + root = Path.expand(root) + + if Volt.Path.inside?(path, root) do + path + else + normalize_from_physical_root(path, root) + end + end) + end + + defp normalize_from_physical_root(path, root) do + with {:ok, root_stat} <- File.stat(root), + {:ok, relative} <- relative_to_physical_root(path, root_stat, []) do + Path.expand(relative, root) + else + _not_found -> nil + end + end + + defp relative_to_physical_root(path, root_stat, parts) do + case File.stat(path) do + {:ok, path_stat} -> + if same_file?(path_stat, root_stat) do + {:ok, Path.join(parts)} + else + continue_to_parent(path, root_stat, parts) + end + + _error -> + continue_to_parent(path, root_stat, parts) + end + end + + defp continue_to_parent(path, root_stat, parts) do + parent = Path.dirname(path) + + if parent == path do + :error + else + relative_to_physical_root(parent, root_stat, [Path.basename(path) | parts]) + end + end + + defp same_file?(left, right) do + left.major_device == right.major_device and left.minor_device == right.minor_device and + left.inode == right.inode + end +end diff --git a/mix.exs b/mix.exs index 572829a..0029516 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.17.10" + @version "0.17.11" @source_url "https://github.com/elixir-volt/volt" def project do diff --git a/test/volt/config_test.exs b/test/volt/config_test.exs index 21f7f04..afc1edb 100644 --- a/test/volt/config_test.exs +++ b/test/volt/config_test.exs @@ -124,6 +124,12 @@ defmodule Volt.ConfigTest do refute Volt.Config.server().watch end + test "watcher ignore patterns are configurable" do + Application.put_env(:volt, :server, watch_ignored: ["**/.generated/**"]) + + assert Volt.Config.server().watch_ignored == ["**/.generated/**"] + end + test "profile server config takes precedence over global :server" do Application.put_env(:volt, :my_app, entry: "my_app/js/app.ts", diff --git a/test/volt/watcher/path_test.exs b/test/volt/watcher/path_test.exs new file mode 100644 index 0000000..e68e048 --- /dev/null +++ b/test/volt/watcher/path_test.exs @@ -0,0 +1,19 @@ +defmodule Volt.Watcher.PathTest do + use ExUnit.Case, async: true + + @tag :tmp_dir + test "normalizes filesystem aliases to a configured root", %{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(Path.join(alias_path, "app.js"), [root]) == + Path.join(root, "app.js") + 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" + end +end diff --git a/test/volt/watcher_test.exs b/test/volt/watcher_test.exs index 45321b1..fdd4498 100644 --- a/test/volt/watcher_test.exs +++ b/test/volt/watcher_test.exs @@ -61,6 +61,72 @@ defmodule Volt.WatcherTest do GenServer.stop(pid) end + test "ignores configured watcher paths", %{watch_dir: watch_dir} do + generated_dir = Path.join(watch_dir, ".generated") + entry = Path.join(generated_dir, "entry.ts") + File.mkdir_p!(generated_dir) + + {:ok, pid} = + Volt.Watcher.start_link( + root: watch_dir, + watch_ignored: [Path.join(generated_dir, "**")], + name: :test_watcher_ignored + ) + + state = send_file_event(pid, entry) + + refute Map.has_key?(state.pending, entry) + GenServer.stop(pid) + end + + test "does not ignore sibling watcher paths", %{watch_dir: watch_dir} do + generated_dir = Path.join(watch_dir, ".generated") + source_file = Path.join([watch_dir, "source", "entry.ts"]) + File.mkdir_p!(Path.dirname(source_file)) + File.write!(source_file, "export const value = 1") + + {:ok, pid} = + Volt.Watcher.start_link( + root: watch_dir, + watch_ignored: [Path.join(generated_dir, "**")], + name: :test_watcher_ignored_sibling + ) + + state = send_file_event(pid, source_file) + + assert Map.has_key?(state.pending, source_file) + Process.cancel_timer(state.pending[source_file]) + GenServer.stop(pid) + end + + test "supports relative watcher ignore globs", %{watch_dir: watch_dir} do + entry = Path.join([watch_dir, ".generated", "entry.ts"]) + + {:ok, pid} = + Volt.Watcher.start_link( + root: watch_dir, + watch_ignored: ["**/.generated/**"], + name: :test_watcher_relative_ignore + ) + + state = send_file_event(pid, entry) + + refute Map.has_key?(state.pending, entry) + GenServer.stop(pid) + end + + test "ignores dependency directories by default", %{watch_dir: watch_dir} do + entry = Path.join([watch_dir, "node_modules", "example", "index.ts"]) + + {:ok, pid} = + Volt.Watcher.start_link(root: watch_dir, name: :test_watcher_default_ignored) + + state = send_file_event(pid, entry) + + refute Map.has_key?(state.pending, entry) + GenServer.stop(pid) + end + test "detects asset changes and broadcasts reload update", %{watch_dir: watch_dir} do Registry.register(Volt.HMR.Registry, :clients, nil) @@ -222,4 +288,13 @@ defmodule Volt.WatcherTest do GenServer.stop(pid) end + + defp send_file_event(pid, path) do + :sys.replace_state(pid, fn state -> + send(pid, {:file_event, self(), {path, [:created]}}) + state + end) + + :sys.get_state(pid) + end end From e189539b5990e43a42c10e3da9a32fb7f8372dba Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 5 Sep 2026 12:34:53 +0300 Subject: [PATCH 038/128] 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 039/128] 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 040/128] 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 041/128] 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 042/128] 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 043/128] 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 044/128] 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 045/128] 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 046/128] 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 047/128] 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 048/128] 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 049/128] 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 050/128] 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 051/128] 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 052/128] 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 053/128] 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 054/128] 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 055/128] 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 056/128] 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 057/128] 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 058/128] 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 5fd284833cf95a9747446290600eb30b7ea2ba43 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 15 Sep 2026 00:50:28 +0300 Subject: [PATCH 059/128] Support per-file lint configuration overrides --- .reach.exs | 1 + CHANGELOG.md | 2 + guides/features/formatting-and-linting.md | 36 +++++++ lib/mix/tasks/volt/lint.ex | 26 +++-- lib/volt/js/check.ex | 59 ++++++----- lib/volt/js/lint/config.ex | 108 ++++++++++++++++++++ mix.exs | 1 + test/mix/tasks/volt/js/check_test.exs | 58 ++++++++++- test/mix/tasks/volt/lint_test.exs | 117 ++++++++++++++++++++++ test/volt/js/lint/config_test.exs | 87 ++++++++++++++++ 10 files changed, 454 insertions(+), 41 deletions(-) create mode 100644 lib/volt/js/lint/config.ex create mode 100644 test/volt/js/lint/config_test.exs diff --git a/.reach.exs b/.reach.exs index 0c8fdc1..8eabe9f 100644 --- a/.reach.exs +++ b/.reach.exs @@ -99,6 +99,7 @@ logic = [ "Volt.HTMLEntry", "Volt.JS.AST", "Volt.JS.Check", + "Volt.JS.Lint.Config", "Volt.JS.Extensions", "Volt.JS.Format", "Volt.JS.Discovery", diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1d68a..0bf6f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- Support root-relative, ordered lint overrides for rules, environments, and globals in both lint commands, including per-component TypeScript rule selection in type-aware checks ([#35](https://github.com/elixir-volt/volt/issues/35)). - 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. @@ -22,6 +23,7 @@ ### Fixed +- Apply configured environments and globals in `mix volt.lint`, matching `mix volt.js.check`. - 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/guides/features/formatting-and-linting.md b/guides/features/formatting-and-linting.md index f5f0a6a..453caaf 100644 --- a/guides/features/formatting-and-linting.md +++ b/guides/features/formatting-and-linting.md @@ -67,6 +67,42 @@ config :volt, :lint, Lint-specific `:root`, `:sources`, and `:ignore` values override the build source set without changing which files the formatter checks. This is useful when canonical fixtures must be linted but retain their original formatting. +### Per-file overrides + +Both `mix volt.lint` and `mix volt.js.check` resolve the same per-file settings: + +```elixir +config :volt, :lint, + root: ".", + sources: ["assets/**/*.js", "scripts/**/*.js"], + plugins: [:typescript, :unicorn], + env: [:browser], + rules: %{"correctness" => :deny, "unicorn/no-null" => :deny}, + overrides: [ + %{ + files: ["assets/colocated/**/*.js"], + rules: %{"unicorn/filename-case" => :allow} + }, + %{ + files: ["assets/js/dom.js"], + rules: %{"unicorn/no-null" => :allow} + }, + %{ + files: ["scripts/**/*.js"], + env: %{browser: false, node: true}, + globals: %{"BuildContext" => :readonly} + } + ] +``` + +- `:files` is a non-empty list of globs relative to the lint root (`:root` under `:lint`, or the build root, which defaults to `assets`). Use `**/*.js` for nested files; `*.js` matches only the root level. Brace alternatives such as `**/*.{js,ts}` are supported. +- Overrides change settings for discovered files; they do not add files or change `:sources`/`:ignore`. Already-discovered dotfiles can match override globs. +- All matching overrides apply in declaration order. Later values win for each rule, environment or global; unrelated inherited entries remain intact. Rule severities are `:allow`, `:warn`, or `:deny`. +- Environment lists enable names; maps can enable or disable them. Set an inherited global to `:off` to remove it, or use `:readonly`/`:writable` to set its access. +- Override entries may be maps or keyword lists and support only `:files`, `:rules`, `:env`, and `:globals`. Plugins and custom rules remain run-wide; `mix volt.lint --plugin` retains its precedence over configured plugins. + +Type-aware checks group files by their effective TypeScript rules. Vue/Svelte script overrides match the original component path, not the generated virtual filename. Every group retains the complete set of extracted script sources, and diagnostics are mapped back to component paths. Environment/global overrides apply to syntax linting, not to TypeScript's project-level compiler configuration. File discovery and formatting settings are unchanged. + ### Custom Rules Custom lint rules can be written in Elixir using the `OXC.Lint.Rule` behaviour — see the [oxc docs](https://hexdocs.pm/oxc/OXC.Lint.Rule.html). diff --git a/lib/mix/tasks/volt/lint.ex b/lib/mix/tasks/volt/lint.ex index 0b5317e..0d9d0f4 100644 --- a/lib/mix/tasks/volt/lint.ex +++ b/lib/mix/tasks/volt/lint.ex @@ -31,7 +31,17 @@ defmodule Mix.Tasks.Volt.Lint do }, custom_rules: [ {MyApp.NoConsoleLog, :warn} + ], + overrides: [ + %{ + files: ["colocated/**/*.js"], + rules: %{"unicorn/filename-case" => :allow} + } ] + + Overrides are relative to the lint root and apply in order. They merge + individual `:rules`, `:env`, and `:globals` entries; later matches win. + Both lint commands share these settings. Plugins remain run-wide. """ use Mix.Task @@ -75,8 +85,7 @@ defmodule Mix.Tasks.Volt.Lint do cli_plugins -> Enum.map(cli_plugins, &String.to_atom/1) end - rules = Keyword.get(config, :rules, %{}) - custom_rules = Keyword.get(config, :custom_rules, []) + lint_config = Volt.JS.Lint.Config.new(config, Volt.Config.build().root) fix = Keyword.get(parsed, :fix, false) files = Volt.JS.Discovery.files(tool: :lint, only: ~w".js .ts .jsx .tsx") @@ -85,21 +94,18 @@ defmodule Mix.Tasks.Volt.Lint do Mix.shell().info("No lintable files found") :ok else - results = lint_files(files, plugins, rules, custom_rules, fix) + results = lint_files(files, lint_config, plugins, fix) print_results(results, files) end end - defp lint_files(files, plugins, rules, custom_rules, fix) do + defp lint_files(files, config, plugins, fix) do Enum.flat_map(files, fn file -> source = File.read!(file) + options = Volt.JS.Lint.Config.options(config, file) + options = Keyword.merge(options, plugins: plugins, fix: fix) - case OXC.Lint.run(source, file, - plugins: plugins, - rules: rules, - custom_rules: custom_rules, - fix: fix - ) do + case OXC.Lint.run(source, file, options) do {:ok, diags} -> Enum.map(diags, &Map.put(&1, :file, file)) diff --git a/lib/volt/js/check.ex b/lib/volt/js/check.ex index f5033f8..696ee8f 100644 --- a/lib/volt/js/check.ex +++ b/lib/volt/js/check.ex @@ -27,13 +27,13 @@ defmodule Volt.JS.Check do def lint(files, opts \\ []) do config = Application.get_env(:volt, :lint, []) - rules = Keyword.get(config, :rules, %{}) + lint_config = Volt.JS.Lint.Config.new(config, Volt.Config.build().root) if opts[:type_aware] do - ast_lint(Enum.filter(files, &type_aware_file?/1), config, rules) ++ - type_aware_lint(files, config, typescript_rules(rules), opts) + ast_lint(Enum.filter(files, &type_aware_file?/1), lint_config) ++ + type_aware_lint(files, config, lint_config, opts) else - ast_lint(files, config, rules) + ast_lint(files, lint_config) end end @@ -55,50 +55,49 @@ defmodule Volt.JS.Check do def lint_error_message(message) when is_binary(message), do: message def lint_error_message(message), do: inspect(message) - defp ast_lint(files, config, rules) do - plugins = Keyword.get(config, :plugins, [:typescript]) - custom_rules = Keyword.get(config, :custom_rules, []) - env = Keyword.get(config, :env, []) - globals = Keyword.get(config, :globals, %{}) - + defp ast_lint(files, config) do Enum.flat_map(files, fn file -> source = File.read!(file) + options = Volt.JS.Lint.Config.options(config, file) - case OXC.Lint.run(source, file, - plugins: plugins, - rules: rules, - env: env, - globals: globals, - custom_rules: custom_rules - ) do + case OXC.Lint.run(source, file, options) do {:ok, diagnostics} -> Enum.map(diagnostics, &Map.put(&1, :file, file)) {:error, errors} -> Enum.map(errors, &lint_error(&1, file)) end end) end - defp type_aware_lint(files, config, rules, opts) do + defp type_aware_lint(files, config, lint_config, opts) do {files, source_overrides, source_files} = type_aware_inputs(files, config) - lint_opts = + common_opts = [ type_aware: true, type_check: opts[:type_check] == true, - rules: rules, source_overrides: Map.merge(source_overrides, Keyword.get(config, :source_overrides, %{})) ] ++ type_aware_options(config) - case run_type_aware_lint(files, lint_opts) do - {:ok, diagnostics} -> - Enum.map(diagnostics, fn diagnostic -> - diagnostic - |> restore_sfc_file(source_files) - |> promote_type_check_diagnostic(opts) - end) + files + |> Enum.group_by(fn file -> + original = Map.get(source_files, Path.expand(file), file) - {:error, errors} -> - Enum.map(errors, &lint_error/1) - end + lint_config + |> Volt.JS.Lint.Config.options(original) + |> Keyword.fetch!(:rules) + |> typescript_rules() + end) + |> Enum.sort_by(fn {rules, _files} -> rules end) + |> Enum.flat_map(fn {rules, batch} -> + case run_type_aware_lint(batch, Keyword.put(common_opts, :rules, rules)) do + {:ok, diagnostics} -> + Enum.map(diagnostics, fn diagnostic -> + diagnostic |> restore_sfc_file(source_files) |> promote_type_check_diagnostic(opts) + end) + + {:error, errors} -> + Enum.map(errors, &lint_error/1) + end + end) end defp type_aware_inputs(files, config) do diff --git a/lib/volt/js/lint/config.ex b/lib/volt/js/lint/config.ex new file mode 100644 index 0000000..e19e931 --- /dev/null +++ b/lib/volt/js/lint/config.ex @@ -0,0 +1,108 @@ +defmodule Volt.JS.Lint.Config do + @moduledoc """ + Resolves lint options for a source file without reading the filesystem. + + Override globs are relative to the lint discovery root. Every matching override + is applied in declaration order; later entries replace individual rules, + environments and globals, not their entire maps. Environment lists enable + names; environment maps can also disable inherited names. + """ + + @enforce_keys [:root, :options, :overrides] + defstruct [:root, :options, :overrides] + + @type t :: %__MODULE__{ + root: String.t(), + options: keyword(), + overrides: [{[GlobEx.t()], keyword()}] + } + + @doc "Compiles a lint configuration, using the build root unless lint sets its own root." + @spec new(keyword(), String.t()) :: t() + def new(config, root) do + options = + [plugins: [:typescript], custom_rules: [], fix: false] + |> Keyword.merge(Keyword.take(config, [:plugins, :custom_rules, :fix])) + |> Keyword.merge(maps(config)) + + overrides = + Enum.map(Keyword.get(config, :overrides, []), fn override -> + override = Map.new(override) + unknown = Map.keys(override) -- [:files, :rules, :env, :globals] + + if unknown != [] do + raise ArgumentError, "unsupported lint override keys: #{inspect(unknown)}" + end + + globs = + case Map.fetch(override, :files) do + {:ok, [_ | _] = files} -> + Enum.map(files, &compile_glob!/1) + + _ -> + raise ArgumentError, + "lint override :files must be a non-empty list of relative globs" + end + + {globs, maps(Map.to_list(override))} + end) + + %__MODULE__{ + root: Path.expand(Keyword.get(config, :root, root)), + options: options, + overrides: overrides + } + end + + @doc "Returns effective OXC syntax-lint options for the original source path." + @spec options(t(), String.t()) :: keyword() + def options(%__MODULE__{} = config, file) do + relative = file |> Path.expand() |> Path.relative_to(config.root) + + if Path.type(relative) == :relative and List.first(Path.split(relative)) != ".." do + relative = relative |> Path.split() |> Enum.join("/") + + Enum.reduce(config.overrides, config.options, &apply_override(&1, relative, &2)) + else + config.options + end + end + + defp apply_override({globs, options}, relative, inherited) do + if Enum.any?(globs, &GlobEx.match?(&1, relative)) do + Keyword.merge(inherited, options, fn _key, base, override -> Map.merge(base, override) end) + else + inherited + end + end + + defp maps(config) do + [ + rules: names(Keyword.get(config, :rules, %{})), + globals: names(Keyword.get(config, :globals, %{})), + env: environments(Keyword.get(config, :env, [])) + ] + end + + defp names(map), do: Map.new(map, fn {name, value} -> {to_string(name), value} end) + + defp environments(env) when is_list(env), do: Map.new(env, &{to_string(&1), true}) + defp environments(env) when is_map(env), do: names(env) + + defp compile_glob!(pattern) when is_binary(pattern) do + if Path.type(pattern) != :relative or ".." in Path.split(pattern) do + raise ArgumentError, + "lint override globs must stay relative to the lint root: #{inspect(pattern)}" + end + + pattern + |> Path.split() + |> Enum.reject(&(&1 == ".")) + |> Enum.join("/") + |> GlobEx.compile!(match_dot: true) + end + + defp compile_glob!(pattern) do + raise ArgumentError, "lint override glob must be a string, got: #{inspect(pattern)}" + end +end diff --git a/mix.exs b/mix.exs index 16f5d12..06ffeed 100644 --- a/mix.exs +++ b/mix.exs @@ -222,6 +222,7 @@ defmodule Volt.MixProject do Volt.JS.AST, Volt.JS.Extensions, Volt.JS.Discovery, + Volt.JS.Lint.Config, Volt.JS.ImportExtractor, Volt.JS.ImportExtractor.Result, Volt.JS.Package, diff --git a/test/mix/tasks/volt/js/check_test.exs b/test/mix/tasks/volt/js/check_test.exs index b6f3bf1..85a0b60 100644 --- a/test/mix/tasks/volt/js/check_test.exs +++ b/test/mix/tasks/volt/js/check_test.exs @@ -154,6 +154,62 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do "svelteValue" end + test "type-aware overrides batch by effective rules and use original SFC paths" do + files = Enum.map(["app.ts", "Component.vue", "Widget.svelte"], &Path.join(@tmp_dir, &1)) + [app, vue, svelte] = files + File.write!(app, "export const value = 1;\n") + File.write!(vue, "\n") + File.write!(svelte, "\n") + payload_path = Path.join(@tmp_dir, "batches.jsonl") + + tsgolint = + fake_executable!(@tmp_dir, "tsgolint-batches", """ + input = IO.binread(:stdio, :eof) + File.write!(#{inspect(payload_path)}, [input, "\\n"], [:append]) + payload = JSON.decode!(input) + for config <- payload["configs"], file <- config["file_paths"] do + json = JSON.encode!(%{rule: "no-floating-promises", message: %{description: "batch diagnostic"}, file_path: file, range: %{pos: 0, end: 1}}) + IO.binwrite(<>) + end + """) + + Application.put_env(:volt, :lint, + root: @tmp_dir, + tsgolint: tsgolint, + rules: %{"typescript/no-floating-promises" => :deny}, + overrides: [ + %{files: ["**/*.{vue,svelte}"], rules: %{"typescript/no-floating-promises" => :warn}}, + %{files: ["**/*.script0.ts"], rules: %{"typescript/no-floating-promises" => :allow}} + ] + ) + + diagnostics = Volt.JS.Check.lint(files, type_aware: true) + + assert Enum.sort(Enum.map(diagnostics, & &1.file)) == + Enum.sort([Path.expand(app), vue, svelte]) + + batches = + payload_path |> File.read!() |> String.split("\n", trim: true) |> Enum.map(&Jason.decode!/1) + + assert length(batches) == 2 + + configs = Enum.flat_map(batches, & &1["configs"]) + assert Enum.sort(Enum.map(configs, &length(&1["file_paths"]))) == [1, 2] + + assert configs |> Enum.flat_map(& &1["rules"]) |> Enum.map(& &1["name"]) |> Enum.uniq() == [ + "no-floating-promises" + ] + + assert Enum.find(diagnostics, &(&1.file == Path.expand(app))).severity == :deny + assert Enum.find(diagnostics, &(&1.file == vue)).severity == :warn + assert Enum.find(diagnostics, &(&1.file == svelte)).severity == :warn + + for batch <- batches do + assert batch["source_overrides"][Path.expand(vue <> ".script0.ts")] =~ "vueValue" + assert batch["source_overrides"][Path.expand(svelte <> ".script0.ts")] =~ "svelteValue" + end + end + defp fake_tsgolint!(dir) do fake_executable!(dir, "tsgolint", """ json = ~s({"rule":"no-floating-promises","message":{"description":"floating promise"},"file_path":"typed.ts","range":{"pos":0,"end":5}}) @@ -188,7 +244,7 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do defp fake_executable!(dir, name, code) do script = Path.expand("#{name}.exs", dir) - File.write!(script, code) + File.write!(script, ":io.setopts(:standard_io, encoding: :latin1)\n" <> code) case :os.type() do {:win32, _name} -> diff --git a/test/mix/tasks/volt/lint_test.exs b/test/mix/tasks/volt/lint_test.exs index c532867..29adcfe 100644 --- a/test/mix/tasks/volt/lint_test.exs +++ b/test/mix/tasks/volt/lint_test.exs @@ -70,6 +70,123 @@ defmodule Mix.Tasks.Volt.LintTest do assert output =~ "no-explicit-any" end + test "generated filenames can be exempted without disabling filename-case elsewhere" do + File.mkdir_p!(Path.join(@tmp_dir, "colocated/DemoWeb")) + generated = Path.join(@tmp_dir, "colocated/DemoWeb/BadName.js") + authored = Path.join(@tmp_dir, "BadName.js") + for file <- [generated, authored], do: File.write!(file, "export const value = 1;\n") + + Application.put_env(:volt, :lint, + plugins: [:unicorn], + rules: %{"unicorn/filename-case" => :deny}, + overrides: [%{files: ["colocated/**/*.js"], rules: %{"unicorn/filename-case" => :allow}}] + ) + + assert [%{file: ^authored, rule: rule}] = Volt.JS.Check.lint([generated, authored]) + assert rule =~ "filename-case" + + output = + capture_io(fn -> assert catch_exit(Mix.Tasks.Volt.Lint.run([])) == {:shutdown, 1} end) + + refute output =~ "colocated/DemoWeb" + assert output =~ "BadName.js" + assert output =~ "filename-case" + end + + test "CLI plugins still enable rules introduced by an override" do + File.write!(Path.join(@tmp_dir, "typed.ts"), "export function foo(x: any) { return x; }\n") + + Application.put_env(:volt, :lint, + plugins: [], + overrides: [%{files: ["typed.ts"], rules: %{"typescript/no-explicit-any" => :warn}}] + ) + + output = capture_io(fn -> Mix.Tasks.Volt.Lint.run(["--plugin", "typescript"]) end) + assert output =~ "no-explicit-any" + end + + test "applies scoped rules, environments and globals in both lint commands" do + File.mkdir_p!(Path.join(@tmp_dir, "scripts")) + script = Path.join(@tmp_dir, "scripts/build.js") + browser = Path.join(@tmp_dir, "browser.js") + File.write!(script, "process.exitCode = externalValue;\nexport const value = null;\n") + File.write!(browser, "document.title = externalValue;\nexport const value = null;\n") + + Application.put_env(:volt, :lint, + root: @tmp_dir, + plugins: [:unicorn], + env: [:browser], + rules: %{"no-undef" => :deny, "unicorn/no-null" => :deny}, + overrides: [ + %{ + files: ["scripts/**/*.js"], + env: %{browser: false, node: true}, + globals: %{"externalValue" => :readonly}, + rules: %{"unicorn/no-null" => :allow} + } + ] + ) + + diagnostics = Volt.JS.Check.lint([script, browser]) + refute Enum.any?(diagnostics, &(&1.file == script)) + assert Enum.any?(diagnostics, &(&1.file == browser and &1.rule =~ "no-null")) + assert Enum.any?(diagnostics, &(&1.file == browser and &1.message =~ "externalValue")) + + output = + capture_io(fn -> assert catch_exit(Mix.Tasks.Volt.Lint.run([])) == {:shutdown, 1} end) + + refute output =~ "scripts/build.js" + assert output =~ "browser.js" + assert output =~ "no-null" + assert output =~ "externalValue" + + for file <- [script, browser] do + {:ok, formatted} = OXC.Format.run(File.read!(file), file, Volt.JS.Format.load_config()) + File.write!(file, formatted) + end + + check_output = + capture_io(:stderr, fn -> + capture_io(fn -> assert catch_exit(Mix.Tasks.Volt.Js.Check.run([])) == {:shutdown, 1} end) + end) + + refute check_output =~ "scripts/build.js" + assert check_output =~ "browser.js" + assert check_output =~ "no-null" + assert check_output =~ "externalValue" + end + + test "overrides can disable inherited environments and globals" do + file = Path.join(@tmp_dir, "server.js") + File.write!(file, "document.title = sharedGlobal;\nprocess.exitCode = 0;\n") + + Application.put_env(:volt, :lint, + env: [:browser], + globals: %{"sharedGlobal" => :readonly}, + rules: %{"no-undef" => :deny}, + overrides: [ + %{ + files: ["server.js"], + env: %{browser: false, node: true}, + globals: %{"sharedGlobal" => :off} + } + ] + ) + + diagnostics = Volt.JS.Check.lint([file]) + assert length(diagnostics) == 2 + assert Enum.any?(diagnostics, &(&1.message =~ "document")) + assert Enum.any?(diagnostics, &(&1.message =~ "sharedGlobal")) + refute Enum.any?(diagnostics, &(&1.message =~ "process")) + + output = + capture_io(fn -> assert catch_exit(Mix.Tasks.Volt.Lint.run([])) == {:shutdown, 1} end) + + assert output =~ "document" + assert output =~ "sharedGlobal" + refute output =~ "process" + end + test "skips node_modules" do File.mkdir_p!(Path.join(@tmp_dir, "node_modules/pkg")) File.write!(Path.join([@tmp_dir, "node_modules", "pkg", "bad.js"]), "debugger;\n") diff --git a/test/volt/js/lint/config_test.exs b/test/volt/js/lint/config_test.exs new file mode 100644 index 0000000..ab37a46 --- /dev/null +++ b/test/volt/js/lint/config_test.exs @@ -0,0 +1,87 @@ +defmodule Volt.JS.Lint.ConfigTest do + use ExUnit.Case, async: true + + alias Volt.JS.Lint.Config + + test "merges all matching overrides in order and normalizes environment/global names" do + config = + Config.new( + [ + rules: %{"correctness" => :deny, "unicorn/no-null" => :deny}, + env: [:browser], + globals: %{shared: :readonly, retained: :readonly}, + overrides: [ + %{ + files: ["scripts/**/*.{js,ts}", "build.js"], + env: %{browser: false, node: true}, + globals: %{"shared" => :writable}, + rules: %{"unicorn/no-null" => :allow} + }, + [ + files: ["scripts/release.*"], + env: [:mocha], + globals: %{shared: :off}, + rules: %{"unicorn/no-null" => :warn} + ] + ] + ], + "assets" + ) + + options = Config.options(config, "assets/scripts/release.ts") + assert options[:rules] == %{"correctness" => :deny, "unicorn/no-null" => :warn} + assert options[:env] == %{"browser" => false, "node" => true, "mocha" => true} + assert options[:globals] == %{"shared" => :off, "retained" => :readonly} + assert options[:plugins] == [:typescript] + assert Config.options(config, "assets/build.js")[:rules]["unicorn/no-null"] == :allow + assert Config.options(config, "assets/app.js")[:rules]["unicorn/no-null"] == :deny + end + + test "uses the lint root, handles absolute paths, and never matches outside the root" do + config = + Config.new( + [ + root: ".", + overrides: [%{files: ["./assets/**/*.js"], rules: %{"no-debugger" => :allow}}] + ], + "other" + ) + + relative = Config.options(config, "assets/nested/file.js") + assert relative[:rules] == %{"no-debugger" => :allow} + assert relative == Config.options(config, Path.expand("assets/nested/file.js")) + assert Config.options(config, "elsewhere/file.js")[:rules] == %{} + + scoped = + Config.new( + [overrides: [%{files: ["**/*.js"], rules: %{"no-debugger" => :allow}}]], + "assets" + ) + + assert Config.options(scoped, "assets-other/file.js")[:rules] == %{} + assert Config.options(scoped, "assets/../file.js")[:rules] == %{} + end + + test "matches already-discovered hidden files without expanding the filesystem" do + config = + Config.new([overrides: [%{files: ["**/*.js"], env: [:node]}]], "hidden/.worktree/assets") + + assert Config.options(config, "hidden/.worktree/assets/.generated/build.js")[:env] == %{ + "node" => true + } + end + + test "requires scoped globs and rejects unsupported override settings" do + for files <- [[], "*.js", [123], ["../*.js"], [Path.expand("*.js")]] do + assert_raise ArgumentError, fn -> Config.new([overrides: [%{files: files}]], "assets") end + end + + assert_raise ArgumentError, ~r/unsupported lint override keys/, fn -> + Config.new([overrides: [%{files: ["*.js"], plugins: [:node]}]], "assets") + end + + assert_raise GlobEx.CompileError, fn -> + Config.new([overrides: [%{files: ["{broken"]}]], "assets") + end + 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 060/128] 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') }) """) From 3fd3aff9186db4083c9c75ba76fb3b6c6c4b8c9e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 15 Sep 2026 09:20:06 +0300 Subject: [PATCH 061/128] Release Volt 0.18.0 --- CHANGELOG.md | 5 ++--- mix.exs | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bf6f15..1474bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.18.0 - 2026-09-15 + ### 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`. @@ -17,9 +19,7 @@ - Require QuickBEAM 0.11.1 or later in the 0.11 series for release-optimized, baseline-CPU precompiled runtimes and the Mint 1.10 security dependency floor. - Write one production manifest at the configured asset output root instead of separate JavaScript and Tailwind manifests. -- Make `mix volt.build` a thin adapter over `Volt.build/1`. - Emit Tailwind development artifacts atomically and skip style HMR broadcasts when generated CSS is unchanged. -- Normalize in-memory build manifests and emitted asset metadata to typed structs before JSON serialization. ### Fixed @@ -32,7 +32,6 @@ - Require Igniter 0.8.4 or later to prevent terminal escape injection through package metadata in installer confirmation prompts (CVE-2026-82584). - Require Bandit 1.12.5 or later in the Phoenix examples to address HTTP/2 header validation and connection-window starvation (CVE-2026-75484, CVE-2026-74836). - - Update Phoenix example locks to patched Phoenix and LiveView releases for channel-join and long-poll denial of service, presence-client crashes, link scheme validation, and redirect validation. ## 0.17.11 - 2026-09-04 diff --git a/mix.exs b/mix.exs index 06ffeed..f02973e 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.17.11" + @version "0.18.0" @source_url "https://github.com/elixir-volt/volt" def project do From 0681fc65cc98a3e5f57d303da473477c91be7c18 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 15 Sep 2026 15:27:20 +0300 Subject: [PATCH 062/128] Wire shared browser types into starter TypeScript configs (#39) --- CHANGELOG.md | 4 + lib/mix/tasks/volt/install.ex | 69 +++++++++++++++++ priv/templates/volt.install/tsconfig.json.eex | 12 +++ priv/types/client/styles.d.ts | 6 ++ test/mix/tasks/volt/install_test.exs | 77 +++++++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 priv/templates/volt.install/tsconfig.json.eex create mode 100644 priv/types/client/styles.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1474bd9..4306522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Wire shared browser declarations into TypeScript configurations during installation. Create a configuration when absent, update ordinary JSON configurations without discarding existing settings, and provide manual guidance for JSONC and inherited or referenced projects. + ## 0.18.0 - 2026-09-15 ### Breaking changes diff --git a/lib/mix/tasks/volt/install.ex b/lib/mix/tasks/volt/install.ex index 9abc231..f643403 100644 --- a/lib/mix/tasks/volt/install.ex +++ b/lib/mix/tasks/volt/install.ex @@ -21,6 +21,17 @@ if Code.ensure_loaded?(Igniter) do 6. Add `Volt.Formatter` plugin to `.formatter.exs` 7. Add `Volt.DevServer` plug to your endpoint 8. Configure the automatic Volt watcher in `config/dev.exs` + 9. Wire Volt client types into TypeScript configuration + + The installer checks `tsconfig.json`, `assets/tsconfig.json`, and + `assets/js/tsconfig.json`. Ordinary JSON configurations retain their settings + and receive explicit declaration files, even when dependencies are excluded. + When none exists, the installer creates a root configuration from a template. + + JSONC and configurations using `extends` or project references are left + unchanged with guidance to include `deps/volt/priv/types/client/**/*.d.ts` + (relative to the browser configuration). No local `ImportMeta` declaration + is needed. """ use Igniter.Mix.Task @@ -58,10 +69,68 @@ if Code.ensure_loaded?(Igniter) do |> add_format_config() |> add_lint_config() |> add_formatter_plugin() + |> add_typescript_config() |> add_dev_config(app_name, endpoint) |> add_dev_server_plug() end + defp add_typescript_config(igniter) do + configs = [ + {"tsconfig.json", "deps"}, + {"assets/tsconfig.json", "../deps"}, + {"assets/js/tsconfig.json", "../../deps"} + ] + + existing = Enum.filter(configs, fn {path, _} -> Igniter.exists?(igniter, path) end) + + case existing do + [] -> + template = Application.app_dir(:volt, "priv/templates/volt.install/tsconfig.json.eex") + Igniter.copy_template(igniter, template, "tsconfig.json", []) + + paths -> + Enum.reduce(paths, igniter, fn {path, deps}, igniter -> + Igniter.update_file(igniter, path, &include_client_types(&1, deps)) + end) + end + end + + defp include_client_types(source, deps) do + with {:ok, config} when is_map(config) <- Jason.decode(Rewrite.Source.get(source, :content)), + false <- Map.has_key?(config, "extends") or Map.has_key?(config, "references"), + files when is_list(files) <- Map.get(config, "files", []), + true <- Enum.all?(files, &is_binary/1) do + types = [ + "#{deps}/volt/priv/types/client/hmr.d.ts", + "#{deps}/volt/priv/types/client/preload.d.ts", + "#{deps}/volt/priv/types/client/styles.d.ts" + ] + + updated = + config + |> preserve_default_include() + |> Map.put("files", Enum.uniq(files ++ types)) + + if updated == config do + source + else + Rewrite.Source.update(source, :content, Jason.encode!(updated, pretty: true) <> "\n") + end + else + _ -> + {:warning, + "Volt left #{source.path} unchanged: JSONC, inherited and referenced projects require " <> + "manual configuration. Include #{deps}/volt/priv/types/client/**/*.d.ts in the browser " <> + "project, ensuring it is not excluded. No application-local ImportMeta declaration is needed."} + end + end + + defp preserve_default_include(config) do + if Map.has_key?(config, "files") or Map.has_key?(config, "include"), + do: config, + else: Map.put(config, "include", ["**/*"]) + end + # ── Remove old tooling ── defp remove_old_deps(igniter) do diff --git a/priv/templates/volt.install/tsconfig.json.eex b/priv/templates/volt.install/tsconfig.json.eex new file mode 100644 index 0000000..0aa0c0f --- /dev/null +++ b/priv/templates/volt.install/tsconfig.json.eex @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "allowJs": true, + "noEmit": true, + "strict": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["assets/**/*", "deps/volt/priv/types/client/**/*.d.ts"] +} diff --git a/priv/types/client/styles.d.ts b/priv/types/client/styles.d.ts new file mode 100644 index 0000000..38f6005 --- /dev/null +++ b/priv/types/client/styles.d.ts @@ -0,0 +1,6 @@ +declare module "*.module.css" { + const classes: Readonly>; + export default classes; +} + +declare module "*.css" {} diff --git a/test/mix/tasks/volt/install_test.exs b/test/mix/tasks/volt/install_test.exs index edf3c53..744d377 100644 --- a/test/mix/tasks/volt/install_test.exs +++ b/test/mix/tasks/volt/install_test.exs @@ -132,9 +132,86 @@ defmodule Mix.Tasks.Volt.InstallTest do igniter.rewrite.sources["mix.exs"] |> Rewrite.Source.get(:content) + tsconfig = + igniter.rewrite.sources["tsconfig.json"] + |> Rewrite.Source.get(:content) + |> Jason.decode!() + + assert tsconfig["compilerOptions"]["strict"] + assert "deps/volt/priv/types/client/**/*.d.ts" in tsconfig["include"] + assert mix_content =~ ~s("assets.setup": []) assert mix_content =~ ~s("assets.build": ["compile", "volt.build --tailwind"]) assert mix_content =~ ~s("assets.deploy": ["volt.build --tailwind", "phx.digest"]) end + + test "preserves options, exclusions and explicit files while adding client types once" do + original = %{ + "compilerOptions" => %{"strict" => false}, + "exclude" => ["deps"], + "files" => ["assets/app.ts"] + } + + igniter = install_with_config("tsconfig.json", Jason.encode!(original)) + config = config(igniter, "tsconfig.json") + assert config["compilerOptions"] == original["compilerOptions"] + assert config["exclude"] == ["deps"] + refute Map.has_key?(config, "include") + + assert config["files"] == [ + "assets/app.ts", + "deps/volt/priv/types/client/hmr.d.ts", + "deps/volt/priv/types/client/preload.d.ts", + "deps/volt/priv/types/client/styles.d.ts" + ] + + assert config(Mix.Tasks.Volt.Install.igniter(igniter), "tsconfig.json") == config + end + + test "retains default source discovery when adding explicit declaration files" do + config = + install_with_config("tsconfig.json", ~s({"compilerOptions":{"strict":true}})) + |> config("tsconfig.json") + + assert config["include"] == ["**/*"] + end + + test "keeps an authored include list" do + config = + install_with_config("tsconfig.json", ~s({"include":["src/**/*.ts"]})) + |> config("tsconfig.json") + + assert config["include"] == ["src/**/*.ts"] + end + + test "updates an assets configuration with paths relative to that configuration" do + igniter = install_with_config("assets/tsconfig.json", ~s({"include":["**/*.ts"]})) + refute Map.has_key?(igniter.rewrite.sources, "tsconfig.json") + + assert "../deps/volt/priv/types/client/hmr.d.ts" in config(igniter, "assets/tsconfig.json")[ + "files" + ] + end + + test "leaves JSONC and inherited or referenced configurations untouched with guidance" do + for content <- [ + "{ // preserve this comment\n}", + ~s({"extends":"./base.json"}), + ~s({"references":[{"path":"./assets"}]}) + ] do + igniter = install_with_config("tsconfig.json", content) + assert Rewrite.Source.get(igniter.rewrite.sources["tsconfig.json"], :content) == content + assert Enum.any?(igniter.warnings, &String.contains?(&1, "manual configuration")) + end + end + end + + defp install_with_config(path, content) do + Test.test_project(app_name: :demo, files: %{path => content}) + |> Mix.Tasks.Volt.Install.igniter() + end + + defp config(igniter, path) do + igniter.rewrite.sources[path] |> Rewrite.Source.get(:content) |> Jason.decode!() end end From 8771ca81bbfbc7d66cbbbf9f5d53eb7bbddb33ff Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Tue, 15 Sep 2026 15:31:45 +0300 Subject: [PATCH 063/128] Release Volt 0.18.1 --- CHANGELOG.md | 3 +++ mix.exs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4306522..053df36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,12 @@ ## Unreleased +## 0.18.1 - 2026-09-15 + ### Added - Wire shared browser declarations into TypeScript configurations during installation. Create a configuration when absent, update ordinary JSON configurations without discarding existing settings, and provide manual guidance for JSONC and inherited or referenced projects. +- Declare CSS side-effect imports and CSS modules in the shared browser declarations. ## 0.18.0 - 2026-09-15 diff --git a/mix.exs b/mix.exs index f02973e..2218b05 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.18.0" + @version "0.18.1" @source_url "https://github.com/elixir-volt/volt" def project do From 7539dea15a94834f9939752cc56fa3165029416e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 30 Sep 2026 05:05:57 +0400 Subject: [PATCH 064/128] Upgrade to oxc 0.18, quickbeam 0.11.2, and json_codec 0.3 Lint diagnostics are OXC.Diagnostic maps, and lint plugin, environment, and global names are strings. Type-aware checks extract component scripts with the configured Volt plugins. --- CHANGELOG.md | 15 ++++++ config/config.exs | 2 +- guides/cheatsheets/configuration.cheatmd | 2 +- guides/features/formatting-and-linting.md | 12 ++--- guides/features/frameworks.md | 8 +-- lib/mix/tasks/volt/install.ex | 2 +- lib/mix/tasks/volt/js/check.ex | 13 +++-- lib/mix/tasks/volt/lint.ex | 60 ++++++----------------- lib/volt/dev/console_forwarder.ex | 2 +- lib/volt/js/check.ex | 32 ++++++------ lib/volt/js/lint/config.ex | 40 +++++++++++---- lib/volt/js/transforms/imports.ex | 5 +- lib/volt/tailwind/loader.ex | 6 ++- lib/volt/test/result.ex | 8 +-- lib/volt/test/sigils.ex | 9 +++- mix.exs | 6 +-- mix.lock | 15 +++--- test/mix/tasks/volt/js/check_test.exs | 16 +++--- test/mix/tasks/volt/lint_test.exs | 20 ++++---- test/volt/js/lint/config_test.exs | 30 +++++++----- test/volt/js/transforms/imports_test.exs | 2 +- 21 files changed, 159 insertions(+), 146 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 053df36..6bc7398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +### Breaking changes + +- Lint diagnostics are `OXC.Diagnostic` maps: severities are `:error` and `:warning`, and locations are 1-based `position` and `span` instead of byte offsets. Parse errors are diagnostics too, without a `rule`. +- Lint `:plugins`, `:env`, and `:globals` take names as strings, as in an oxlint config file, such as `plugins: ["typescript", "react"]` and `env: ["browser"]`. Atom names raise an `ArgumentError`. +- Custom lint rules report findings with AST `start` and `end` offsets instead of `span`. +- `Volt.JS.Transforms.Imports.rewrite!/3` raises `OXC.Error` on parse errors. + +### Changed + +- Require `oxc` 0.18, `quickbeam` 0.11.2, and `json_codec` 0.3. + +### Fixed + +- Type-aware checks extract single-file component scripts with the configured Volt plugins instead of the oxlint plugin names. + ## 0.18.1 - 2026-09-15 ### Added diff --git a/config/config.exs b/config/config.exs index 98a050e..98e14d3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -9,7 +9,7 @@ config :volt, :format, arrow_parens: :always config :volt, :lint, - plugins: [:typescript, :import, :unicorn], + plugins: ["typescript", "import", "unicorn"], rules: %{ "no-unused-expressions" => :allow, "no-unused-vars" => :warn, diff --git a/guides/cheatsheets/configuration.cheatmd b/guides/cheatsheets/configuration.cheatmd index 00ca691..e25567b 100644 --- a/guides/cheatsheets/configuration.cheatmd +++ b/guides/cheatsheets/configuration.cheatmd @@ -295,7 +295,7 @@ config :volt, :format, arrow_parens: :always ### Plugins ```elixir -config :volt, :lint, plugins: [:typescript, :react] +config :volt, :lint, plugins: ["typescript", "react"] ``` ### Rules diff --git a/guides/features/formatting-and-linting.md b/guides/features/formatting-and-linting.md index 453caaf..d195a0a 100644 --- a/guides/features/formatting-and-linting.md +++ b/guides/features/formatting-and-linting.md @@ -47,7 +47,7 @@ mix volt.lint mix volt.lint --plugin react --plugin typescript ``` -Available plugins: `react`, `typescript`, `unicorn`, `import`, `jsdoc`, `jest`, `vitest`, `jsx_a11y`, `nextjs`, `react_perf`, `promise`, `node`, `vue`, `oxc`. +Plugin, environment, and global names are strings, as in an oxlint config file: `"react"`, `"unicorn"`, `"jsx-a11y"`, and so on. Unknown plugin names are reported as errors. ### Configuration @@ -55,9 +55,9 @@ Available plugins: `react`, `typescript`, `unicorn`, `import`, `jsdoc`, `jest`, config :volt, :lint, sources: ["priv/ts/**/*.ts", "test/javascript/**/*.mjs"], ignore: ["test/javascript/fixtures/**"], - env: [:browser, :node, :mocha], + env: ["browser", "node", "mocha"], globals: %{"AppRuntime" => :readonly}, - plugins: [:typescript], + plugins: ["typescript"], rules: %{ "no-debugger" => :deny, "eqeqeq" => :deny, @@ -75,8 +75,8 @@ Both `mix volt.lint` and `mix volt.js.check` resolve the same per-file settings: config :volt, :lint, root: ".", sources: ["assets/**/*.js", "scripts/**/*.js"], - plugins: [:typescript, :unicorn], - env: [:browser], + plugins: ["typescript", "unicorn"], + env: ["browser"], rules: %{"correctness" => :deny, "unicorn/no-null" => :deny}, overrides: [ %{ @@ -89,7 +89,7 @@ config :volt, :lint, }, %{ files: ["scripts/**/*.js"], - env: %{browser: false, node: true}, + env: %{"browser" => false, "node" => true}, globals: %{"BuildContext" => :readonly} } ] diff --git a/guides/features/frameworks.md b/guides/features/frameworks.md index 5beee9e..7848099 100644 --- a/guides/features/frameworks.md +++ b/guides/features/frameworks.md @@ -15,7 +15,7 @@ config :volt, sources: ["**/*.{js,ts,jsx,tsx}"], import_source: "react" -config :volt, :lint, plugins: [:typescript, :react] +config :volt, :lint, plugins: ["typescript", "react"] ``` ```json @@ -62,7 +62,7 @@ config :volt, ] ] -config :volt, :lint, plugins: [:typescript, :vue] +config :volt, :lint, plugins: ["typescript", "vue"] ``` ```json @@ -115,7 +115,7 @@ config :volt, ] ] -config :volt, :lint, plugins: [:typescript] +config :volt, :lint, plugins: ["typescript"] ``` ```json @@ -155,7 +155,7 @@ config :volt, sources: ["**/*.{js,ts,jsx,tsx}"], plugins: [Volt.Plugin.Solid] -config :volt, :lint, plugins: [:typescript] +config :volt, :lint, plugins: ["typescript"] ``` ```json diff --git a/lib/mix/tasks/volt/install.ex b/lib/mix/tasks/volt/install.ex index f643403..64bf414 100644 --- a/lib/mix/tasks/volt/install.ex +++ b/lib/mix/tasks/volt/install.ex @@ -259,7 +259,7 @@ if Code.ensure_loaded?(Igniter) do {:code, Sourceror.parse_string!(""" [ - plugins: [:typescript, :import, :unicorn], + plugins: ["typescript", "import", "unicorn"], rules: %{ "no-debugger" => :deny, "no-unused-vars" => :warn, diff --git a/lib/mix/tasks/volt/js/check.ex b/lib/mix/tasks/volt/js/check.ex index 9556868..04efd22 100644 --- a/lib/mix/tasks/volt/js/check.ex +++ b/lib/mix/tasks/volt/js/check.ex @@ -45,9 +45,7 @@ defmodule Mix.Tasks.Volt.Js.Check do Mix.shell().error("#{length(errors)} file(s) could not be formatted:") Enum.each(errors, fn {file, format_errors} -> - Mix.shell().error( - " #{file}: #{format_errors |> List.wrap() |> Enum.map_join(", ", &Volt.JS.Check.lint_error_message/1)}" - ) + Mix.shell().error(" #{file}: #{Enum.map_join(format_errors, ", ", & &1.message)}") end) false @@ -94,12 +92,13 @@ defmodule Mix.Tasks.Volt.Js.Check do end defp print_lint_diags(diags) do - errors = Enum.count(diags, &(&1.severity == :deny)) - warnings = Enum.count(diags, &(&1.severity == :warn)) + errors = Enum.count(diags, &(&1.severity == :error)) + warnings = Enum.count(diags, &(&1.severity == :warning)) Enum.each(diags, fn diag -> - tag = if diag.severity == :deny, do: "error", else: "warn" - Mix.shell().error(" [#{tag}] #{diag.file}: #{diag.message} (#{diag.rule})") + location = if location = Volt.JS.Check.location(diag), do: "#{location}: ", else: "" + rule = if rule = diag[:rule], do: " (#{rule})", else: "" + Mix.shell().error(" [#{diag.severity}] #{location}#{diag.message}#{rule}") end) Mix.shell().error("#{errors} error(s), #{warnings} warning(s)") diff --git a/lib/mix/tasks/volt/lint.ex b/lib/mix/tasks/volt/lint.ex index 0d9d0f4..91216ae 100644 --- a/lib/mix/tasks/volt/lint.ex +++ b/lib/mix/tasks/volt/lint.ex @@ -11,10 +11,8 @@ defmodule Mix.Tasks.Volt.Lint do ## Options - * `--plugin` — enable an oxlint plugin (repeatable). - Available: `react`, `typescript`, `unicorn`, `import`, `jsdoc`, - `jest`, `vitest`, `jsx_a11y`, `nextjs`, `react_perf`, `promise`, - `node`, `vue`, `oxc` + * `--plugin` — enable an oxlint plugin (repeatable), such as `react`, + `unicorn`, or `jsx-a11y`. Accepts the plugin names of an oxlint config file. * `--fix` — show fix suggestions in output @@ -23,7 +21,7 @@ defmodule Mix.Tasks.Volt.Lint do Configure lint settings in `config :volt, :lint`: config :volt, :lint, - plugins: [:typescript, :react], + plugins: ["typescript", "react"], rules: %{ "no-console" => :warn, "eqeqeq" => :deny, @@ -81,8 +79,8 @@ defmodule Mix.Tasks.Volt.Lint do plugins = case Keyword.get_values(parsed, :plugin) do - [] -> Keyword.get(config, :plugins, [:typescript]) - cli_plugins -> Enum.map(cli_plugins, &String.to_atom/1) + [] -> Keyword.get(config, :plugins, ["typescript"]) + cli_plugins -> cli_plugins end lint_config = Volt.JS.Lint.Config.new(config, Volt.Config.build().root) @@ -106,21 +104,8 @@ defmodule Mix.Tasks.Volt.Lint do options = Keyword.merge(options, plugins: plugins, fix: fix) case OXC.Lint.run(source, file, options) do - {:ok, diags} -> - Enum.map(diags, &Map.put(&1, :file, file)) - - {:error, parse_errors} -> - Enum.map(parse_errors, fn msg -> - %{ - file: file, - rule: "parse-error", - message: msg, - severity: :deny, - span: {0, 0}, - labels: [], - help: nil - } - end) + {:ok, diags} -> diags + {:error, parse_errors} -> parse_errors end end) end @@ -171,8 +156,6 @@ defmodule Mix.Tasks.Volt.Lint do defp print_diagnostic(diag, edge_color) do tag = severity_tag(diag.severity) arrow = priority_arrow(diag.severity) - source = File.read!(diag.file) - {line, col} = offset_to_line_col(source, elem(diag.span, 0)) Mix.shell().info( IO.ANSI.format([ @@ -196,20 +179,17 @@ defmodule Mix.Tasks.Volt.Lint do "┃ ", :reset, :faint, - diag.file, + Volt.JS.Check.location(diag) || "", :default_color, :faint, - ":#{line}:#{col}", - " #(", - diag.rule, - ")" + if(rule = diag[:rule], do: " #(#{rule})", else: "") ]) ) end defp print_summary(results, files) do - errors = Enum.count(results, &(&1.severity == :deny)) - warnings = Enum.count(results, &(&1.severity == :warn)) + errors = Enum.count(results, &(&1.severity == :error)) + warnings = Enum.count(results, &(&1.severity == :warning)) by_category = results @@ -260,25 +240,15 @@ defmodule Mix.Tasks.Volt.Lint do end) end - defp severity_tag(:deny), do: "[E]" - defp severity_tag(:warn), do: "[W]" - defp severity_tag(_), do: "[I]" + defp severity_tag(:error), do: "[E]" + defp severity_tag(:warning), do: "[W]" - defp priority_arrow(:deny), do: "↗" - defp priority_arrow(:warn), do: "→" - defp priority_arrow(_), do: "→" + defp priority_arrow(:error), do: "↗" + defp priority_arrow(:warning), do: "→" defp pl(1), do: "" defp pl(_), do: "s" - defp offset_to_line_col(source, offset) do - prefix = binary_part(source, 0, min(offset, byte_size(source))) - lines = String.split(prefix, "\n") - line = length(lines) - col = lines |> List.last() |> String.length() |> Kernel.+(1) - {line, col} - end - defp term_columns do case :io.columns() do {:ok, cols} -> cols diff --git a/lib/volt/dev/console_forwarder.ex b/lib/volt/dev/console_forwarder.ex index 81fe6b0..b60b08f 100644 --- a/lib/volt/dev/console_forwarder.ex +++ b/lib/volt/dev/console_forwarder.ex @@ -9,7 +9,7 @@ defmodule Volt.Dev.ConsoleForwarder do defmodule Payload do @moduledoc false - use JSONCodec, fast_path: :json + use JSONCodec defstruct [:level, :source, :args] diff --git a/lib/volt/js/check.ex b/lib/volt/js/check.ex index 696ee8f..6a9a21b 100644 --- a/lib/volt/js/check.ex +++ b/lib/volt/js/check.ex @@ -39,36 +39,38 @@ defmodule Volt.JS.Check do def promote_type_check_diagnostic(%{rule: rule} = diagnostic, opts) do if Keyword.get(opts, :type_check, false) and type_check_diagnostic?(rule) do - %{diagnostic | severity: :deny} + %{diagnostic | severity: :error} else diagnostic end end + @doc "Formats a diagnostic's location as `file:line:column`, `file`, or `nil` without a file." + @spec location(OXC.Diagnostic.t()) :: String.t() | nil + def location(%{file: nil}), do: nil + def location(%{file: file, position: {line, column}}), do: "#{file}:#{line}:#{column}" + def location(%{file: file}), do: file + def type_check_diagnostic?(rule) do rule |> to_string() |> String.match?(~r/^(typescript\/)?TS\d+$/) end - def lint_error_message(%{message: message}), do: message - def lint_error_message(message) when is_binary(message), do: message - def lint_error_message(message), do: inspect(message) - defp ast_lint(files, config) do Enum.flat_map(files, fn file -> source = File.read!(file) options = Volt.JS.Lint.Config.options(config, file) case OXC.Lint.run(source, file, options) do - {:ok, diagnostics} -> Enum.map(diagnostics, &Map.put(&1, :file, file)) - {:error, errors} -> Enum.map(errors, &lint_error(&1, file)) + {:ok, diagnostics} -> diagnostics + {:error, errors} -> errors end end) end defp type_aware_lint(files, config, lint_config, opts) do - {files, source_overrides, source_files} = type_aware_inputs(files, config) + {files, source_overrides, source_files} = type_aware_inputs(files) common_opts = [ @@ -95,13 +97,13 @@ defmodule Volt.JS.Check do end) {:error, errors} -> - Enum.map(errors, &lint_error/1) + errors end end) end - defp type_aware_inputs(files, config) do - plugins = Keyword.get(config, :plugins, []) + defp type_aware_inputs(files) do + plugins = Volt.Config.build().plugins Enum.reduce(files, {[], %{}, %{}}, fn file, {files, overrides, source_files} -> if type_aware_file?(file) do @@ -170,7 +172,7 @@ defmodule Volt.JS.Check do defp unknown_tsgolint_rule(errors) do Enum.find_value(errors, fn error -> - case Regex.run(~r/unknown rule: ([\w-]+)/, lint_error_message(error)) do + case Regex.run(~r/unknown rule: ([\w-]+)/, error.message) do [_, rule] -> rule _ -> nil end @@ -188,10 +190,4 @@ defmodule Volt.JS.Check do |> Keyword.take([:tsgolint, :fix, :fix_suggestions, :cwd]) |> Enum.reject(fn {_key, value} -> is_nil(value) end) end - - defp lint_error(message), do: lint_error(message, "volt.js.check") - - defp lint_error(message, file) do - %{severity: :deny, file: file, message: lint_error_message(message), rule: "oxc/lint"} - end end diff --git a/lib/volt/js/lint/config.ex b/lib/volt/js/lint/config.ex index e19e931..f28c4b8 100644 --- a/lib/volt/js/lint/config.ex +++ b/lib/volt/js/lint/config.ex @@ -2,6 +2,9 @@ defmodule Volt.JS.Lint.Config do @moduledoc """ Resolves lint options for a source file without reading the filesystem. + Plugin, rule, environment, and global names are strings, as in an oxlint + config file. + Override globs are relative to the lint discovery root. Every matching override is applied in declaration order; later entries replace individual rules, environments and globals, not their entire maps. Environment lists enable @@ -21,10 +24,12 @@ defmodule Volt.JS.Lint.Config do @spec new(keyword(), String.t()) :: t() def new(config, root) do options = - [plugins: [:typescript], custom_rules: [], fix: false] + [plugins: ["typescript"], custom_rules: [], fix: false] |> Keyword.merge(Keyword.take(config, [:plugins, :custom_rules, :fix])) |> Keyword.merge(maps(config)) + names!(options[:plugins], :plugins) + overrides = Enum.map(Keyword.get(config, :overrides, []), fn override -> override = Map.new(override) @@ -62,12 +67,18 @@ defmodule Volt.JS.Lint.Config do if Path.type(relative) == :relative and List.first(Path.split(relative)) != ".." do relative = relative |> Path.split() |> Enum.join("/") - Enum.reduce(config.overrides, config.options, &apply_override(&1, relative, &2)) + config.overrides + |> Enum.reduce(config.options, &apply_override(&1, relative, &2)) + |> enabled_environments() else - config.options + enabled_environments(config.options) end end + defp enabled_environments(options) do + Keyword.update!(options, :env, fn env -> for {name, true} <- env, do: name end) + end + defp apply_override({globs, options}, relative, inherited) do if Enum.any?(globs, &GlobEx.match?(&1, relative)) do Keyword.merge(inherited, options, fn _key, base, override -> Map.merge(base, override) end) @@ -78,16 +89,27 @@ defmodule Volt.JS.Lint.Config do defp maps(config) do [ - rules: names(Keyword.get(config, :rules, %{})), - globals: names(Keyword.get(config, :globals, %{})), - env: environments(Keyword.get(config, :env, [])) + rules: named!(Keyword.get(config, :rules, %{}), :rules), + globals: named!(Keyword.get(config, :globals, %{}), :globals), + env: environments!(Keyword.get(config, :env, [])) ] end - defp names(map), do: Map.new(map, fn {name, value} -> {to_string(name), value} end) + defp environments!(env) when is_list(env), do: env |> names!(:env) |> Map.new(&{&1, true}) + defp environments!(env) when is_map(env), do: named!(env, :env) - defp environments(env) when is_list(env), do: Map.new(env, &{to_string(&1), true}) - defp environments(env) when is_map(env), do: names(env) + defp named!(map, key) when is_map(map) do + names!(Map.keys(map), key) + map + end + + defp names!(names, key) do + unless is_list(names) and Enum.all?(names, &is_binary/1) do + raise ArgumentError, "lint #{inspect(key)} names must be strings, got: #{inspect(names)}" + end + + names + end defp compile_glob!(pattern) when is_binary(pattern) do if Path.type(pattern) != :relative or ".." in Path.split(pattern) do diff --git a/lib/volt/js/transforms/imports.ex b/lib/volt/js/transforms/imports.ex index 9ac36ce..1538666 100644 --- a/lib/volt/js/transforms/imports.ex +++ b/lib/volt/js/transforms/imports.ex @@ -40,10 +40,7 @@ defmodule Volt.JS.Transforms.Imports do @spec rewrite!(String.t(), String.t(), (String.t() -> {:rewrite, String.t()} | :keep)) :: String.t() def rewrite!(source, filename, rewrite_fn) do - case rewrite(source, filename, rewrite_fn) do - {:ok, result} -> result - {:error, errors} -> raise "Import rewrite error: #{inspect(errors)}" - end + source |> rewrite(filename, rewrite_fn) |> OXC.Error.unwrap!() end @doc """ diff --git a/lib/volt/tailwind/loader.ex b/lib/volt/tailwind/loader.ex index 8e550ab..042f77e 100644 --- a/lib/volt/tailwind/loader.ex +++ b/lib/volt/tailwind/loader.ex @@ -83,7 +83,11 @@ defmodule Volt.Tailwind.Loader do {code, dependencies} {:error, errors} -> - raise "Could not bundle Tailwind module #{inspect(entry_path)}: #{inspect(errors)}" + raise OXC.Error, + message: + "Could not bundle Tailwind module #{inspect(entry_path)}:\n" <> + Enum.map_join(errors, "\n", &OXC.Diagnostic.format/1), + errors: errors end else {:error, reason} -> diff --git a/lib/volt/test/result.ex b/lib/volt/test/result.ex index b293afb..4358513 100644 --- a/lib/volt/test/result.ex +++ b/lib/volt/test/result.ex @@ -3,7 +3,7 @@ defmodule Volt.Test.Result do defmodule Metadata do @moduledoc "Metadata collected for a registered JavaScript test before execution." - use JSONCodec, case: :camel, fast_path: :json + use JSONCodec, case: :camel defstruct [:id, :name, :full_name, :suite, :mode, :skip_reason, tags: [], line: nil] @@ -21,7 +21,7 @@ defmodule Volt.Test.Result do defmodule SerializedError do @moduledoc "Serialized JavaScript error details returned by a failed test." - use JSONCodec, fast_path: :json + use JSONCodec defstruct [:name, :message, :stack, :expected, :actual] @@ -36,7 +36,7 @@ defmodule Volt.Test.Result do defmodule Test do @moduledoc "Execution result for one registered JavaScript test." - use JSONCodec, case: :camel, fast_path: :json + use JSONCodec, case: :camel defstruct [:id, :name, :full_name, :status, :duration, :error, :skip_reason] @@ -51,7 +51,7 @@ defmodule Volt.Test.Result do } end - use JSONCodec, fast_path: :json + use JSONCodec defstruct [:file, :status, :duration, :total, :failed, :skipped, tests: []] diff --git a/lib/volt/test/sigils.ex b/lib/volt/test/sigils.ex index a262c61..e17143b 100644 --- a/lib/volt/test/sigils.ex +++ b/lib/volt/test/sigils.ex @@ -63,8 +63,13 @@ defmodule Volt.Test.Sigils do defp validate!(source, filename) do case OXC.parse(source, filename) do - {:ok, _ast} -> :ok - {:error, errors} -> raise ArgumentError, "invalid #{filename} source: #{inspect(errors)}" + {:ok, _ast} -> + :ok + + {:error, errors} -> + raise ArgumentError, + "invalid #{filename} source:\n" <> + Enum.map_join(errors, "\n", &OXC.Diagnostic.format/1) end end end diff --git a/mix.exs b/mix.exs index 2218b05..199e9f9 100644 --- a/mix.exs +++ b/mix.exs @@ -38,10 +38,10 @@ defmodule Volt.MixProject do [ {:reach, "~> 2.6.1", only: [:dev, :test], runtime: false}, {:glob_ex, "~> 0.1.12"}, - {:oxc, "~> 0.17.8"}, + {:oxc, "~> 0.18.0"}, {:vize, "~> 0.14.2"}, {:oxide_ex, "~> 0.2.2"}, - {:quickbeam, "~> 0.11.1"}, + {:quickbeam, "~> 0.11.2"}, {:dotenvy, "~> 1.1"}, {:floki, "~> 0.38"}, {:plug, "~> 1.16"}, @@ -49,7 +49,7 @@ defmodule Volt.MixProject do {:websock_adapter, "~> 0.5"}, {:file_system, "~> 1.0"}, {:jason, "~> 1.4"}, - {:json_codec, "~> 0.2.3"}, + {:json_codec, "~> 0.3"}, {:igniter, ">= 0.8.4 and < 1.0.0", optional: true}, {:npm, "~> 0.7.6"}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index eebb01e..261efcb 100644 --- a/mix.lock +++ b/mix.lock @@ -17,21 +17,22 @@ "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, "ex_slop": {:hex, :ex_slop, "0.4.2", "142aba9a82eddfb258e39c45d59392ab3cdb6b5a3ad401b09b362b7134fc54eb", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: false]}], "hexpm", "c7f5316f755f83566e7a0a049f6fedfcd5ff916fce83c6ebfdf806be62fd7a69"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, + "finch": {:hex, :finch, "0.24.0", "4022b6194e907b6d9b597c168001cac38e488a35fe36cef9d8297e3159782510", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "33ba40069c3587c2f99f9125b766e19dad87d6d54be3c6961db2304df04cef00"}, "floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"}, "glob_ex": {:hex, :glob_ex, "0.1.12", "7b2d9369c20e2697efcfd185d13d6e84c94cd3bfd2730fbde613141c2e015c00", [:mix], [], "hexpm", "2e2fac83f113514434c7eaf267b4c38af2f91766f1cab2c5db7053b7fc1ee0bb"}, "hex_solver": {:hex, :hex_solver, "0.3.0", "81e7659ad6caba1f856d89fc7ca52f88e8eb4f801deedcdce215f9006d706d1d", [:mix], [], "hexpm", "8a04c8ef0df25ca1f5e4d7d5f32833fac569f5c6442f05e216ee33615593e64d"}, - "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, "igniter": {:hex, :igniter, "0.8.4", "f79f1bbdc2fb7b9ca030a22d12a585b060cbf5b94b9d3f23b1148578a9e05d11", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "a9b1cbec996ccb100b4f7d8130129b2dd3f18eb4224ac9a0e907e428ca90dbd7"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, - "json_codec": {:hex, :json_codec, "0.2.3", "b75b2f76a2c89844a72f2dcc8f83c045d0fb030da041b9c2278c82f70067ec78", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4dad674cbee1119161b555a155ef1537908ee337d49e619e1b155313ec523b71"}, + "json_codec": {:hex, :json_codec, "0.3.1", "81f738e212308c85344e11da00af6d8b52bd486224ee24c1f51b95df0604aeae", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:json_spec, "~> 1.2", [hex: :json_spec, repo: "hexpm", optional: false]}], "hexpm", "dffea3198d3f1fe9a39aa176ab0d6988b8c80dd85a67e913b323d4ec7c57ca9d"}, + "json_spec": {:hex, :json_spec, "1.2.0", "c67f73cdc4166e955b2f1095d3f44dd78c197337038f1a5301fb526b6c87ab04", [:mix], [], "hexpm", "3639961babad7d6bc45f3a2d96a49c18ec70614761bb84ce58b64e9fad9354de"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "makeup_js": {:hex, :makeup_js, "0.1.0", "ffa8ce9db95d14dcd09045334539d5992d540d63598c592d4805b7674bdd6675", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "3f0c1a5eb52c9737b1679c926574e83bb260ccdedf08b58ee96cca7c685dea75"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.10.0", "85af3353bfc504f5bdfe494bd92b8490f87a306dc659ee1ad0af435107e898dc", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "8b16fb72aaa7531d206a1f05e4cc85509ba531ccec7a17a22736c9c95cbb24d1"}, + "mint": {:hex, :mint, "1.11.0", "a713551624815c0435237b93d90ea8b9b14254690c66f732d0ef8930f76ff1d9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "c6279ba2d6aa3a383a1d4cfbe7b59f42e6efd400f58d8e2acfeac48a438693ab"}, "mint_web_socket": {:hex, :mint_web_socket, "1.0.6", "5ffcf350df5b90f2d7a04adf877165228804993714592512374218d4679e325a", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "0c360e9012413f1c115a63532601eb5d63731aab7010949178769760686c1698"}, "muontrap": {:hex, :muontrap, "1.8.0", "3c5dab5bc91d5a16d14cd85b130ae7d35a9c469564a11ba4b2ebbcd7ccf5bc05", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "45ff36451303cbdefe1c66da3681b5aff078ee5737348f554f0017daa66284af"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, @@ -40,7 +41,7 @@ "npm": {:hex, :npm, "0.7.6", "de8ca3dd26a40fac7ab8a17680088b8d55bab0e0294be9770b029c276361482f", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:npm_semver, "~> 0.1.0", [hex: :npm_semver, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "e0a1ce77e3e49ec108e095aba133bd315e86da298dba26b142a105972141d9ef"}, "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, - "oxc": {:hex, :oxc, "0.17.8", "e4c50ee0c0ce09107016b5153132b3d98421665355189b531c78ae4603047853", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "590fddfadbf843d6ab94e0f04a0e769a38358a23140728095da476c8c81f4df1"}, + "oxc": {:hex, :oxc, "0.18.0", "0b22ae1ac62f6e985458aae53bb0020756bd790ed07bcda1a89168b266025415", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "121495bbbaba0807433653d4446cb9ed68bc7db35e6f11a9c82da56f89bcaa6c"}, "oxide_ex": {:hex, :oxide_ex, "0.2.2", "063e0d2899e81632b7efc7849eb1dd89a7a623290a075c446b43bef14cf5795e", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "577a2fbd97c46904a31143bb700cdb374105085cfd1f7605416bb6c012cb7f60"}, "pegasus": {:hex, :pegasus, "0.2.6", "b4af6522326fbb2ffd1bb706e78ec05854fbcb4b03a7fe08f8db2e9de1d0be67", [:mix], [{:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0ac159f0ccab7967cf90208327cc8a35788874814c8d78e19d47104d3fc049b9"}, "pi_bridge": {:hex, :pi_bridge, "0.6.21", "f1a63da5f49fef4f6b35e98edd5400cbd64a49fb8bda84be41b7e91f3deb0b89", [:mix], [{:bandit, "~> 1.8", [hex: :bandit, repo: "hexpm", optional: false]}, {:dune, "~> 0.3", [hex: :dune, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_ast, "~> 0.12", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.1.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:quackdb, "~> 0.5.4", [hex: :quackdb, repo: "hexpm", optional: false]}, {:reach, "~> 2.6", [hex: :reach, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:req_llm, "~> 1.6", [hex: :req_llm, repo: "hexpm", optional: true]}], "hexpm", "b226fb60965a8e928186af3c3b96fe8f0fefd8c6cc7be679d6e169ba84105de9"}, @@ -49,12 +50,12 @@ "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, "protoss": {:hex, :protoss, "1.1.0", "853533313989751c7da5b0e1ce71501a23f3dc41f128709478af40daccc6e959", [:mix], [], "hexpm", "c2f874383dd047fcfdf467b814dd33a208a4d24a467aef90d86185b41a0752ad"}, "quackdb": {:hex, :quackdb, "0.5.13", "854294a5a85e0f2972d2229da8dfb25afd2cda500e80e477f6987bff823c2b21", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.7", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:explorer, "~> 0.11", [hex: :explorer, repo: "hexpm", optional: true]}, {:fsst, "~> 0.1.2", [hex: :fsst, repo: "hexpm", optional: true]}, {:geo, "~> 4.1", [hex: :geo, repo: "hexpm", optional: true]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:muontrap, "~> 1.5", [hex: :muontrap, repo: "hexpm", optional: false]}, {:table, "~> 0.1", [hex: :table, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:varint, "~> 1.6", [hex: :varint, repo: "hexpm", optional: false]}], "hexpm", "a5d18e54de682bde9f4ab1866126aa90c26223866bc693bf56cdf9dafa3859d3"}, - "quickbeam": {:hex, :quickbeam, "0.11.1", "55e7700b2a16c8df246402f8400908222e906f3b463af4ac8706f748d83b66db", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.10", [hex: :mint, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.6", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.17.8", [hex: :oxc, repo: "hexpm", optional: false]}, {:varint, "~> 1.6", [hex: :varint, repo: "hexpm", optional: false]}, {:zigler, "~> 0.16.0", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.6", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "6c0240b9d016bbc174319ef004e81f2893ae2e77939d6b9b87c5542b344d0b87"}, + "quickbeam": {:hex, :quickbeam, "0.11.2", "ea4e4bfd7a8999381f86b773b13d028c02753137f8b56c1d3f3cce4649dfee71", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: true]}, {:mint, "~> 1.10", [hex: :mint, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.6", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.17.8 or ~> 0.18.0", [hex: :oxc, repo: "hexpm", optional: false]}, {:varint, "~> 1.6", [hex: :varint, repo: "hexpm", optional: false]}, {:zigler, "~> 0.16.0", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.6", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "f7652948f43484fcc5c0fe007fdcddbd62db595daa16a6f53fb9b3553f66c608"}, "reach": {:hex, :reach, "2.6.1", "33f749a1e15baa843aedd3e3e9a282322eef1e6c59d9b8a5072c8427359f9658", [:mix], [{:boxart, "~> 0.3.3", [hex: :boxart, repo: "hexpm", optional: true]}, {:ex_ast, "~> 0.12.0", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:ex_dna, "~> 1.5", [hex: :ex_dna, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:libgraph, "~> 0.16.0", [hex: :libgraph, repo: "hexpm", optional: false]}, {:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: true]}, {:makeup_js, "~> 0.1", [hex: :makeup_js, repo: "hexpm", optional: true]}, {:quickbeam, "~> 0.10", [hex: :quickbeam, repo: "hexpm", optional: true]}], "hexpm", "2d7e8a26a999be50cf716cf5786c5d1570d04b397ee047d430cab1d6956714cc"}, "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, "rustler": {:hex, :rustler, "0.38.0", "7a8906998ff0d28e3021c0a73264abcda719bda344b2e58307c6805b0f87c9b4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "704c03c1bf66be12b031c5a389347b91c81c5cb819a24b068b0de36fe4a5652a"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.10.0", "8f07ff0865cb09a6a0f75e11701e9a46e331e05f737da4e5b01374dcd25b68c0", [:mix], [{:rustler, "~> 0.28", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "c68c9dc732c12acfdf4a7fa06f4e6f48a641179e6be49a474c8a60809929c430"}, "sourceror": {:hex, :sourceror, "1.12.2", "85bfd48159f020c0cbfc72f289f11456fdc05dc43719b6f2589fb969faefa113", [:mix], [], "hexpm", "da37d3da09c5b890528802c7056a8f585a061973820d7656b6e3649c14f0e9cb"}, "spitfire": {:hex, :spitfire, "0.4.1", "69e90335d00ca328295e1e1e77cac5d7575aa6d34274e3467ebfc654b8858be3", [:mix], [], "hexpm", "27d86f67681179682b15c6758d64ac2eb2b3637ed8340800c8b885c69754cdcd"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, diff --git a/test/mix/tasks/volt/js/check_test.exs b/test/mix/tasks/volt/js/check_test.exs index 85a0b60..88dd353 100644 --- a/test/mix/tasks/volt/js/check_test.exs +++ b/test/mix/tasks/volt/js/check_test.exs @@ -47,7 +47,7 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do File.write!(file, "document; describe; knownGlobal(); missingGlobal();\n") Application.put_env(:volt, :lint, - env: [:browser, :mocha], + env: ["browser", "mocha"], globals: %{"knownGlobal" => :readonly}, rules: %{"no-undef" => :deny} ) @@ -81,12 +81,12 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do test "type-check diagnostics are promoted to errors" do diagnostic = %{ rule: "typescript/TS2322", - severity: :warn, + severity: :warning, file: "typed.ts", message: "Type number is not assignable to type string." } - assert %{severity: :deny} = + assert %{severity: :error} = Volt.JS.Check.promote_type_check_diagnostic(diagnostic, type_check: true) end @@ -168,7 +168,7 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do File.write!(#{inspect(payload_path)}, [input, "\\n"], [:append]) payload = JSON.decode!(input) for config <- payload["configs"], file <- config["file_paths"] do - json = JSON.encode!(%{rule: "no-floating-promises", message: %{description: "batch diagnostic"}, file_path: file, range: %{pos: 0, end: 1}}) + json = JSON.encode!(%{kind: 0, rule: "no-floating-promises", message: %{id: "floatingPromise", description: "batch diagnostic"}, file_path: file, range: %{pos: 0, end: 1}}) IO.binwrite(<>) end """) @@ -200,9 +200,9 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do "no-floating-promises" ] - assert Enum.find(diagnostics, &(&1.file == Path.expand(app))).severity == :deny - assert Enum.find(diagnostics, &(&1.file == vue)).severity == :warn - assert Enum.find(diagnostics, &(&1.file == svelte)).severity == :warn + assert Enum.find(diagnostics, &(&1.file == Path.expand(app))).severity == :error + assert Enum.find(diagnostics, &(&1.file == vue)).severity == :warning + assert Enum.find(diagnostics, &(&1.file == svelte)).severity == :warning for batch <- batches do assert batch["source_overrides"][Path.expand(vue <> ".script0.ts")] =~ "vueValue" @@ -212,7 +212,7 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do defp fake_tsgolint!(dir) do fake_executable!(dir, "tsgolint", """ - json = ~s({"rule":"no-floating-promises","message":{"description":"floating promise"},"file_path":"typed.ts","range":{"pos":0,"end":5}}) + json = ~s({"kind":0,"rule":"no-floating-promises","message":{"id":"floatingPromise","description":"floating promise"},"file_path":"typed.ts","range":{"pos":0,"end":5}}) IO.binwrite(<>) """) end diff --git a/test/mix/tasks/volt/lint_test.exs b/test/mix/tasks/volt/lint_test.exs index 29adcfe..4682aa3 100644 --- a/test/mix/tasks/volt/lint_test.exs +++ b/test/mix/tasks/volt/lint_test.exs @@ -27,7 +27,7 @@ defmodule Mix.Tasks.Volt.LintTest do test "reports no issues for clean code" do File.write!(Path.join(@tmp_dir, "clean.ts"), "export const x = 1;\n") - Application.put_env(:volt, :lint, plugins: [:typescript]) + Application.put_env(:volt, :lint, plugins: ["typescript"]) output = capture_io(fn -> Mix.Tasks.Volt.Lint.run([]) end) assert output =~ "No issues found" @@ -50,7 +50,7 @@ defmodule Mix.Tasks.Volt.LintTest do File.write!(Path.join(@tmp_dir, "typed.ts"), "export function foo(x: any) { return x; }\n") Application.put_env(:volt, :lint, - plugins: [:typescript], + plugins: ["typescript"], rules: %{"typescript/no-explicit-any" => :warn} ) @@ -77,7 +77,7 @@ defmodule Mix.Tasks.Volt.LintTest do for file <- [generated, authored], do: File.write!(file, "export const value = 1;\n") Application.put_env(:volt, :lint, - plugins: [:unicorn], + plugins: ["unicorn"], rules: %{"unicorn/filename-case" => :deny}, overrides: [%{files: ["colocated/**/*.js"], rules: %{"unicorn/filename-case" => :allow}}] ) @@ -114,13 +114,13 @@ defmodule Mix.Tasks.Volt.LintTest do Application.put_env(:volt, :lint, root: @tmp_dir, - plugins: [:unicorn], - env: [:browser], + plugins: ["unicorn"], + env: ["browser"], rules: %{"no-undef" => :deny, "unicorn/no-null" => :deny}, overrides: [ %{ files: ["scripts/**/*.js"], - env: %{browser: false, node: true}, + env: %{"browser" => false, "node" => true}, globals: %{"externalValue" => :readonly}, rules: %{"unicorn/no-null" => :allow} } @@ -161,13 +161,13 @@ defmodule Mix.Tasks.Volt.LintTest do File.write!(file, "document.title = sharedGlobal;\nprocess.exitCode = 0;\n") Application.put_env(:volt, :lint, - env: [:browser], + env: ["browser"], globals: %{"sharedGlobal" => :readonly}, rules: %{"no-undef" => :deny}, overrides: [ %{ files: ["server.js"], - env: %{browser: false, node: true}, + env: %{"browser" => false, "node" => true}, globals: %{"sharedGlobal" => :off} } ] @@ -224,8 +224,8 @@ defmodule Mix.Tasks.Volt.LintTest do @impl true def run(ast, _ctx) do OXC.collect(ast, fn - %{type: :debugger_statement, start: s, end: e} -> - {:keep, %{span: {s, e}, message: "custom debugger ban"}} + %{type: :debugger_statement, start: start, end: stop} -> + {:keep, %{start: start, end: stop, message: "custom debugger ban"}} _ -> :skip diff --git a/test/volt/js/lint/config_test.exs b/test/volt/js/lint/config_test.exs index ab37a46..3ca4a52 100644 --- a/test/volt/js/lint/config_test.exs +++ b/test/volt/js/lint/config_test.exs @@ -3,24 +3,24 @@ defmodule Volt.JS.Lint.ConfigTest do alias Volt.JS.Lint.Config - test "merges all matching overrides in order and normalizes environment/global names" do + test "merges all matching overrides in order and resolves enabled environments" do config = Config.new( [ rules: %{"correctness" => :deny, "unicorn/no-null" => :deny}, - env: [:browser], - globals: %{shared: :readonly, retained: :readonly}, + env: ["browser"], + globals: %{"shared" => :readonly, "retained" => :readonly}, overrides: [ %{ files: ["scripts/**/*.{js,ts}", "build.js"], - env: %{browser: false, node: true}, + env: %{"browser" => false, "node" => true}, globals: %{"shared" => :writable}, rules: %{"unicorn/no-null" => :allow} }, [ files: ["scripts/release.*"], - env: [:mocha], - globals: %{shared: :off}, + env: ["mocha"], + globals: %{"shared" => :off}, rules: %{"unicorn/no-null" => :warn} ] ] @@ -30,9 +30,9 @@ defmodule Volt.JS.Lint.ConfigTest do options = Config.options(config, "assets/scripts/release.ts") assert options[:rules] == %{"correctness" => :deny, "unicorn/no-null" => :warn} - assert options[:env] == %{"browser" => false, "node" => true, "mocha" => true} + assert Enum.sort(options[:env]) == ["mocha", "node"] assert options[:globals] == %{"shared" => :off, "retained" => :readonly} - assert options[:plugins] == [:typescript] + assert options[:plugins] == ["typescript"] assert Config.options(config, "assets/build.js")[:rules]["unicorn/no-null"] == :allow assert Config.options(config, "assets/app.js")[:rules]["unicorn/no-null"] == :deny end @@ -64,11 +64,9 @@ defmodule Volt.JS.Lint.ConfigTest do test "matches already-discovered hidden files without expanding the filesystem" do config = - Config.new([overrides: [%{files: ["**/*.js"], env: [:node]}]], "hidden/.worktree/assets") + Config.new([overrides: [%{files: ["**/*.js"], env: ["node"]}]], "hidden/.worktree/assets") - assert Config.options(config, "hidden/.worktree/assets/.generated/build.js")[:env] == %{ - "node" => true - } + assert Config.options(config, "hidden/.worktree/assets/.generated/build.js")[:env] == ["node"] end test "requires scoped globs and rejects unsupported override settings" do @@ -77,7 +75,13 @@ defmodule Volt.JS.Lint.ConfigTest do end assert_raise ArgumentError, ~r/unsupported lint override keys/, fn -> - Config.new([overrides: [%{files: ["*.js"], plugins: [:node]}]], "assets") + Config.new([overrides: [%{files: ["*.js"], plugins: ["node"]}]], "assets") + end + + for config <- [[plugins: [:react]], [env: [:browser]], [globals: %{jQuery: :readonly}]] do + assert_raise ArgumentError, ~r/names must be strings/, fn -> + Config.new(config, "assets") + end end assert_raise GlobEx.CompileError, fn -> diff --git a/test/volt/js/transforms/imports_test.exs b/test/volt/js/transforms/imports_test.exs index 3634754..07ef1a4 100644 --- a/test/volt/js/transforms/imports_test.exs +++ b/test/volt/js/transforms/imports_test.exs @@ -138,7 +138,7 @@ defmodule Volt.JS.Transforms.ImportsTest do end test "raises on parse error" do - assert_raise RuntimeError, fn -> + assert_raise OXC.Error, ~r"bad.js:1:", fn -> Volt.JS.Transforms.Imports.rewrite!("const = ;", "bad.js", fn _ -> :keep end) end end From 5fb1019b32ecd6b5861784954c700d2458af9bb4 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 30 Sep 2026 05:22:29 +0400 Subject: [PATCH 065/128] Use string lint plugin names in examples --- examples/react/config/config.exs | 2 +- examples/solid/config/config.exs | 2 +- examples/svelte/config/config.exs | 2 +- examples/vanilla/config/config.exs | 2 +- examples/vanilla/mix.lock | 14 +++++++------- examples/vue/config/config.exs | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/examples/react/config/config.exs b/examples/react/config/config.exs index fbb5c48..8c8f13d 100644 --- a/examples/react/config/config.exs +++ b/examples/react/config/config.exs @@ -38,7 +38,7 @@ config :volt, :format, single_quote: true config :volt, :lint, - plugins: [:typescript, :react], + plugins: ["typescript", "react"], tsgolint: System.find_executable("tsgolint"), rules: %{ "correctness" => :deny, diff --git a/examples/solid/config/config.exs b/examples/solid/config/config.exs index d40c8a5..595d9f0 100644 --- a/examples/solid/config/config.exs +++ b/examples/solid/config/config.exs @@ -38,7 +38,7 @@ config :volt, :format, single_quote: true config :volt, :lint, - plugins: [:typescript], + plugins: ["typescript"], tsgolint: System.find_executable("tsgolint"), rules: %{ "correctness" => :deny, diff --git a/examples/svelte/config/config.exs b/examples/svelte/config/config.exs index 8cc9c41..e367836 100644 --- a/examples/svelte/config/config.exs +++ b/examples/svelte/config/config.exs @@ -37,7 +37,7 @@ config :volt, :format, single_quote: true config :volt, :lint, - plugins: [:typescript], + plugins: ["typescript"], tsgolint: System.find_executable("tsgolint"), rules: %{ "correctness" => :deny, diff --git a/examples/vanilla/config/config.exs b/examples/vanilla/config/config.exs index d4840f1..aba95e3 100644 --- a/examples/vanilla/config/config.exs +++ b/examples/vanilla/config/config.exs @@ -37,7 +37,7 @@ config :volt, :format, single_quote: true config :volt, :lint, - plugins: [:typescript], + plugins: ["typescript"], tsgolint: System.find_executable("tsgolint"), rules: %{ "correctness" => :deny, diff --git a/examples/vanilla/mix.lock b/examples/vanilla/mix.lock index 9c4872f..faa11b0 100644 --- a/examples/vanilla/mix.lock +++ b/examples/vanilla/mix.lock @@ -4,23 +4,23 @@ "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"}, + "finch": {:hex, :finch, "0.24.0", "4022b6194e907b6d9b597c168001cac38e488a35fe36cef9d8297e3159782510", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "33ba40069c3587c2f99f9125b766e19dad87d6d54be3c6961db2304df04cef00"}, "floki": {:hex, :floki, "0.38.1", "f002ccac94b3bcb21d40d9b34cc2cc9fd88a8311879120330075b5dde657ebee", [:mix], [], "hexpm", "e744bf0db7ee34b2c8b62767f04071107af0516a81144b9a2f73fe0494200e5b"}, "glob_ex": {:hex, :glob_ex, "0.1.12", "7b2d9369c20e2697efcfd185d13d6e84c94cd3bfd2730fbde613141c2e015c00", [:mix], [], "hexpm", "2e2fac83f113514434c7eaf267b4c38af2f91766f1cab2c5db7053b7fc1ee0bb"}, "hex_solver": {:hex, :hex_solver, "0.3.0", "81e7659ad6caba1f856d89fc7ca52f88e8eb4f801deedcdce215f9006d706d1d", [:mix], [], "hexpm", "8a04c8ef0df25ca1f5e4d7d5f32833fac569f5c6442f05e216ee33615593e64d"}, - "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, - "json_codec": {:hex, :json_codec, "0.2.5", "4463d9db729ba994c356a0598b9a88ec0956a77e7ab778318d128b43db665faa", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_spec, "~> 1.2", [hex: :json_spec, repo: "hexpm", optional: false]}], "hexpm", "b32f189249cda0954a5fb6f4aab720444b5f3f81272869ce90098eea2418d465"}, + "json_codec": {:hex, :json_codec, "0.3.1", "81f738e212308c85344e11da00af6d8b52bd486224ee24c1f51b95df0604aeae", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:json_spec, "~> 1.2", [hex: :json_spec, repo: "hexpm", optional: false]}], "hexpm", "dffea3198d3f1fe9a39aa176ab0d6988b8c80dd85a67e913b323d4ec7c57ca9d"}, "json_spec": {:hex, :json_spec, "1.2.0", "c67f73cdc4166e955b2f1095d3f44dd78c197337038f1a5301fb526b6c87ab04", [:mix], [], "hexpm", "3639961babad7d6bc45f3a2d96a49c18ec70614761bb84ce58b64e9fad9354de"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.10.0", "85af3353bfc504f5bdfe494bd92b8490f87a306dc659ee1ad0af435107e898dc", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "8b16fb72aaa7531d206a1f05e4cc85509ba531ccec7a17a22736c9c95cbb24d1"}, + "mint": {:hex, :mint, "1.11.0", "a713551624815c0435237b93d90ea8b9b14254690c66f732d0ef8930f76ff1d9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "c6279ba2d6aa3a383a1d4cfbe7b59f42e6efd400f58d8e2acfeac48a438693ab"}, "mint_web_socket": {:hex, :mint_web_socket, "1.0.6", "5ffcf350df5b90f2d7a04adf877165228804993714592512374218d4679e325a", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "0c360e9012413f1c115a63532601eb5d63731aab7010949178769760686c1698"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "npm": {:hex, :npm, "0.7.6", "de8ca3dd26a40fac7ab8a17680088b8d55bab0e0294be9770b029c276361482f", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:npm_semver, "~> 0.1.0", [hex: :npm_semver, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "e0a1ce77e3e49ec108e095aba133bd315e86da298dba26b142a105972141d9ef"}, "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, - "oxc": {:hex, :oxc, "0.17.8", "e4c50ee0c0ce09107016b5153132b3d98421665355189b531c78ae4603047853", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "590fddfadbf843d6ab94e0f04a0e769a38358a23140728095da476c8c81f4df1"}, + "oxc": {:hex, :oxc, "0.18.0", "0b22ae1ac62f6e985458aae53bb0020756bd790ed07bcda1a89168b266025415", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "121495bbbaba0807433653d4446cb9ed68bc7db35e6f11a9c82da56f89bcaa6c"}, "oxide_ex": {:hex, :oxide_ex, "0.2.2", "063e0d2899e81632b7efc7849eb1dd89a7a623290a075c446b43bef14cf5795e", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "577a2fbd97c46904a31143bb700cdb374105085cfd1f7605416bb6c012cb7f60"}, "phoenix": {:hex, :phoenix, "1.8.14", "9279cbbcd755ac8d5f42d206915e7df13b9ab49096b98114f1c99ff908e5b42e", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "2782ff375824b2b5e41561fbae4764ee7b875af6898483bca49f24a9d1e37816"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, @@ -30,9 +30,9 @@ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, - "quickbeam": {:hex, :quickbeam, "0.11.1", "55e7700b2a16c8df246402f8400908222e906f3b463af4ac8706f748d83b66db", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.10", [hex: :mint, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.6", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.17.8", [hex: :oxc, repo: "hexpm", optional: false]}, {:varint, "~> 1.6", [hex: :varint, repo: "hexpm", optional: false]}, {:zigler, "~> 0.16.0", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.6", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "6c0240b9d016bbc174319ef004e81f2893ae2e77939d6b9b87c5542b344d0b87"}, + "quickbeam": {:hex, :quickbeam, "0.11.2", "ea4e4bfd7a8999381f86b773b13d028c02753137f8b56c1d3f3cce4649dfee71", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: true]}, {:mint, "~> 1.10", [hex: :mint, repo: "hexpm", optional: false]}, {:mint_web_socket, "~> 1.0", [hex: :mint_web_socket, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:npm, "~> 0.7.6", [hex: :npm, repo: "hexpm", optional: true]}, {:oxc, "~> 0.17.8 or ~> 0.18.0", [hex: :oxc, repo: "hexpm", optional: false]}, {:varint, "~> 1.6", [hex: :varint, repo: "hexpm", optional: false]}, {:zigler, "~> 0.16.0", [hex: :zigler, repo: "hexpm", optional: true]}, {:zigler_precompiled, "~> 0.1.6", [hex: :zigler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "f7652948f43484fcc5c0fe007fdcddbd62db595daa16a6f53fb9b3553f66c608"}, "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, - "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.10.0", "8f07ff0865cb09a6a0f75e11701e9a46e331e05f737da4e5b01374dcd25b68c0", [:mix], [{:rustler, "~> 0.28", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "c68c9dc732c12acfdf4a7fa06f4e6f48a641179e6be49a474c8a60809929c430"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, diff --git a/examples/vue/config/config.exs b/examples/vue/config/config.exs index 71b1590..e27ff43 100644 --- a/examples/vue/config/config.exs +++ b/examples/vue/config/config.exs @@ -37,7 +37,7 @@ config :volt, :format, single_quote: true config :volt, :lint, - plugins: [:typescript, :vue], + plugins: ["typescript", "vue"], tsgolint: System.find_executable("tsgolint"), rules: %{ "correctness" => :deny, From da837523f1bedbe636939807c923245ad6d87fe4 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 30 Sep 2026 05:22:29 +0400 Subject: [PATCH 066/128] Show development errors with locations on page load and after fixes The dev server keeps the current errors per session, replays them to clients that connect, and adds the HMR client to rendered HTML pages so the overlay still appears when a page's module graph fails to load. The overlay shows file, line, column, and a source frame, hides once errors are fixed, and the page reloads after the HMR connection returns. --- .reach.exs | 3 + CHANGELOG.md | 10 +++ guides/features/features.md | 2 +- guides/features/hmr.md | 7 +- lib/volt/application.ex | 1 + lib/volt/dev/cleanup.ex | 1 + lib/volt/dev/error.ex | 104 +++++++++++++++++++++++++ lib/volt/dev_server.ex | 43 ++++------ lib/volt/dev_server/client_tag.ex | 45 +++++++++++ lib/volt/hmr.ex | 27 ++++++- lib/volt/hmr/errors.ex | 23 ++++++ lib/volt/hmr/message.ex | 3 +- lib/volt/hmr/socket.ex | 6 +- lib/volt/watcher.ex | 48 +++++++----- priv/ts/client/hmr.ts | 20 ++++- priv/ts/client/overlay.ts | 67 ++++++++++++---- test/support/volt/dev_server_case.ex | 7 +- test/volt/dev/error_test.exs | 50 ++++++++++++ test/volt/dev_server/requests_test.exs | 41 +++++++++- test/volt/hmr/socket_test.exs | 14 ++++ test/volt/hmr_test.exs | 16 +++- 21 files changed, 454 insertions(+), 84 deletions(-) create mode 100644 lib/volt/dev/error.ex create mode 100644 lib/volt/dev_server/client_tag.ex create mode 100644 lib/volt/hmr/errors.ex create mode 100644 test/volt/dev/error_test.exs diff --git a/.reach.exs b/.reach.exs index 8eabe9f..a7541bb 100644 --- a/.reach.exs +++ b/.reach.exs @@ -139,6 +139,8 @@ infrastructure = [ "Volt.Cache", "Volt.Dev", "Volt.Dev.Cleanup", + "Volt.Dev.Error", + "Volt.DevServer.ClientTag", "Volt.Dev.Assets", "Volt.Dev.Session.Call", "Volt.Dev.Session.State", @@ -147,6 +149,7 @@ infrastructure = [ "Volt.Dev.ConsoleForwarder.Payload", "Volt.ETS", "Volt.HMR.Boundary", + "Volt.HMR.Errors", "Volt.HMR.GlobGraph", "Volt.HMR.ImportGraph", "Volt.HMR.ModuleGraph", diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bc7398..e18ecb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ - Lint `:plugins`, `:env`, and `:globals` take names as strings, as in an oxlint config file, such as `plugins: ["typescript", "react"]` and `env: ["browser"]`. Atom names raise an `ArgumentError`. - Custom lint rules report findings with AST `start` and `end` offsets instead of `span`. - `Volt.JS.Transforms.Imports.rewrite!/3` raises `OXC.Error` on parse errors. +- HMR `error` messages carry the current `errors` as `Volt.Dev.Error` entries instead of a `path` and `reason`; an empty list hides the overlay. + +### Added + +- The error overlay shows each error's file, line, column, source frame, and hint. +- `Volt.HMR.clear_error/2` hides errors reported with `Volt.HMR.error/3`. Errors stay current until cleared, so browsers that connect later show them. +- The dev server adds the HMR client to HTML pages the app renders. ### Changed @@ -15,6 +22,9 @@ ### Fixed +- Show the error overlay when a page loads with a module that fails to compile. The client previously loaded only through the failing module graph. +- Hide the overlay once errors are fixed, and report Tailwind build failures, including at startup. +- Reload the page after the HMR connection returns, since the server may have restarted. - Type-aware checks extract single-file component scripts with the configured Volt plugins instead of the oxlint plugin names. ## 0.18.1 - 2026-09-15 diff --git a/guides/features/features.md b/guides/features/features.md index ede89ea..7920118 100644 --- a/guides/features/features.md +++ b/guides/features/features.md @@ -210,7 +210,7 @@ In development, browser `console.log`, `console.warn`, and `console.error` calls ## Error Overlay -Compilation errors in development are displayed as a full-screen browser overlay with the error message. The overlay dismisses on click and clears automatically when the error is fixed. +Compilation errors in development are displayed as a full-screen browser overlay with each error's file, line, column, and source frame. It appears on page load as well as during HMR, dismisses on click, and clears automatically when the error is fixed. ## Plugins diff --git a/guides/features/hmr.md b/guides/features/hmr.md index fe5bc66..9f209f5 100644 --- a/guides/features/hmr.md +++ b/guides/features/hmr.md @@ -37,7 +37,7 @@ with the session's state generation. | `.ex`, `.heex`, `.eex` | Incremental Tailwind rebuild, CSS hot-swap | | `.vue` (style-only change) | CSS hot-swap, no page reload | -The browser client auto-reconnects on disconnect and shows compilation errors as an overlay. +The browser client auto-reconnects on disconnect, reloading the page when the connection returns, and shows compilation errors as an overlay. The dev server adds the client to HTML pages the app renders, so the overlay also appears when a page's scripts fail to load. ## Server-side broadcasts @@ -53,10 +53,13 @@ Volt.HMR.full_reload("content/posts/hello.md") # Send a custom update payload, optionally with an HMR boundary Volt.HMR.update("src/counter.ts", [:hmr], boundary: "/assets/counter.ts") -# Show the browser error overlay +# Show the browser error overlay, then hide it once the file is fixed Volt.HMR.error("content/posts/hello.md", "Invalid frontmatter") +Volt.HMR.clear_error("content/posts/hello.md") ``` +Errors can be messages, exceptions, or `OXC.Diagnostic` maps with a location, which the overlay shows with a source frame. They stay current until cleared, so browsers that connect later see them too. + Use this when an external package owns additional dependency graphs, such as pages, layouts, or content collections, while Volt serves the asset graph. `Volt.HMR.invalidate_file/1` evicts Volt's dev compilation state for a source file and marks its module-graph nodes invalidated without broadcasting. Use it before sending your own update when an external package knows a file's compiled output is stale. diff --git a/lib/volt/application.ex b/lib/volt/application.ex index 10c810b..c613cc5 100644 --- a/lib/volt/application.ex +++ b/lib/volt/application.ex @@ -10,6 +10,7 @@ defmodule Volt.Application do Volt.HMR.GlobGraph.create_table() Volt.HMR.StyleGraph.create_table() Volt.HMR.ModuleGraph.create_table() + Volt.HMR.Errors.create_table() children = [ {Registry, keys: :duplicate, name: Volt.HMR.Registry}, diff --git a/lib/volt/dev/cleanup.ex b/lib/volt/dev/cleanup.ex index 1613654..ac813c3 100644 --- a/lib/volt/dev/cleanup.ex +++ b/lib/volt/dev/cleanup.ex @@ -9,6 +9,7 @@ defmodule Volt.Dev.Cleanup do Volt.HMR.GlobGraph.clear_session(session) Volt.HMR.StyleGraph.clear_session(session) Volt.HMR.ModuleGraph.clear_session(session) + Volt.HMR.Errors.clear_session(session) :ok end end diff --git a/lib/volt/dev/error.ex b/lib/volt/dev/error.ex new file mode 100644 index 0000000..673bfdb --- /dev/null +++ b/lib/volt/dev/error.ex @@ -0,0 +1,104 @@ +defmodule Volt.Dev.Error do + @moduledoc """ + JSON-safe error entries for the development error overlay. + + Build errors arrive as `OXC.Diagnostic` maps, compiler messages, exceptions, or + arbitrary terms. `entries/2` turns any of them into maps the browser overlay + renders, with a source frame when the file and line are known. + """ + + @type entry :: %{ + message: String.t(), + file: String.t() | nil, + line: pos_integer() | nil, + column: pos_integer() | nil, + hint: String.t() | nil, + frame: String.t() | nil + } + + @doc """ + Build overlay entries from an error `reason`. + + ## Options + + * `:file` — the source path the errors belong to. It names entries that have + no file or only its basename, and is read for source frames. + """ + @spec entries(term(), keyword()) :: [entry()] + def entries(reason, opts \\ []) + + def entries(errors, opts) when is_list(errors), do: Enum.flat_map(errors, &entries(&1, opts)) + + def entries(%{__exception__: true} = exception, opts), + do: [entry(Exception.message(exception), opts, [])] + + def entries(%{message: message} = diagnostic, opts) when is_binary(message) do + {line, column} = position(diagnostic[:position]) + hint = if is_binary(diagnostic[:details]), do: diagnostic[:details] + + [entry(message, opts, file: diagnostic[:file], line: line, column: column, hint: hint)] + end + + def entries(message, opts) when is_binary(message), do: [entry(message, opts, [])] + def entries(reason, opts), do: [entry(inspect(reason), opts, [])] + + defp entry(message, opts, fields) do + file = source_file(fields[:file], opts[:file]) + line = fields[:line] + column = fields[:column] + + %{ + message: message, + file: file && Path.relative_to_cwd(file), + line: line, + column: column, + hint: fields[:hint], + frame: frame(file, line, column) + } + end + + defp source_file(nil, path), do: path + defp source_file(file, nil), do: file + + defp source_file(file, path) do + if file == Path.basename(path), do: path, else: file + end + + defp position({line, column}), do: {line, column} + defp position(line) when is_integer(line) and line > 0, do: {line, nil} + defp position(_position), do: {nil, nil} + + defp frame(file, line, column) when is_binary(file) and is_integer(line) do + with {:ok, source} <- File.read(file), + lines = String.split(source, ~r/\r?\n/), + true <- line <= length(lines) do + first = max(line - 2, 1) + last = min(line + 1, length(lines)) + width = last |> Integer.to_string() |> byte_size() + + lines + |> Enum.slice((first - 1)..(last - 1)//1) + |> Enum.with_index(first) + |> Enum.flat_map(&frame_rows(&1, line, column, width)) + |> Enum.join("\n") + else + _ -> nil + end + end + + defp frame(_file, _line, _column), do: nil + + defp frame_rows({text, number}, line, column, width) do + gutter = number |> Integer.to_string() |> String.pad_leading(width) + + if number == line do + row = "> #{gutter} | #{text}" + + if column, + do: [row, " #{String.duplicate(" ", width)} | #{String.duplicate(" ", column - 1)}^"], + else: [row] + else + [" #{gutter} | #{text}"] + end + end +end diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index a9326fe..4009b8c 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -335,7 +335,7 @@ defmodule Volt.DevServer do serve(conn, relative, config) :no_match -> - conn + Volt.DevServer.ClientTag.register(conn) end end end @@ -359,6 +359,7 @@ defmodule Volt.DevServer do defp compile_and_serve_virtual(conn, id, source, content_type, config) do case Volt.Pipeline.compile(id, source, pipeline_opts(config, id)) do {:ok, result} -> + Volt.HMR.clear_error(id, session: config.session) content_type = content_type || Volt.MIME.javascript() mod_url = virtual_url(id) code = code_for_request(result, mod_url, content_type, false) @@ -376,10 +377,7 @@ defmodule Volt.DevServer do send_compiled(conn, code, result.sourcemap, content_type) {:error, errors} -> - conn - |> Conn.put_resp_content_type(Volt.MIME.javascript()) - |> Conn.send_resp(500, error_overlay(errors)) - |> Conn.halt() + send_compile_error(conn, id, errors, config) end end @@ -476,6 +474,7 @@ defmodule Volt.DevServer do case Volt.Pipeline.compile(module_id, source, pipeline_opts(config, module_id)) do {:ok, result} -> + Volt.HMR.clear_error(file_path, session: config.session) Volt.HMR.GlobGraph.update_from_source(file_path, source, config.tables || config.session) Volt.HMR.ImportGraph.update_from_compiled( @@ -518,10 +517,7 @@ defmodule Volt.DevServer do send_compiled(conn, code, result.sourcemap, content_type) {:error, errors} -> - conn - |> Conn.put_resp_content_type(Volt.MIME.javascript()) - |> Conn.send_resp(500, error_overlay(errors)) - |> Conn.halt() + send_compile_error(conn, file_path, errors, config) end end @@ -943,25 +939,16 @@ defmodule Volt.DevServer do if Volt.MIME.javascript?(content_type), do: Volt.Dev.ConsoleForwarder.inject(code), else: code end - defp error_overlay(errors) do - msg = - errors - |> List.wrap() - |> Enum.map_join("\n", fn - %{message: m} -> m - e when is_binary(e) -> e - e -> inspect(e) - end) + # The HMR client shows the errors: a failed module fails its importers before + # any script in this response could run. + defp send_compile_error(conn, path, errors, config) do + Volt.HMR.error(path, errors, session: config.session) + message = "[Volt] Could not compile #{Path.relative_to_cwd(path)}" - overlay = support_module!("client/overlay.ts") - overlay <> "\n" <> error_overlay_invocation(msg) - end - - defp error_overlay_invocation(message) do - "renderErrorOverlay($message, $options)" - |> OXC.parse!("volt-error-overlay-call.ts") - |> OXC.bind(message: {:literal, message}, options: {:literal, %{title: "Compilation error"}}) - |> OXC.codegen!() + conn + |> Conn.put_resp_content_type(Volt.MIME.javascript()) + |> Conn.send_resp(500, "throw new Error(#{Jason.encode!(message)})") + |> Conn.halt() end defp client_module!(heartbeat_interval) do @@ -976,8 +963,6 @@ defmodule Volt.DevServer do end end - defp support_module!(relative), do: Volt.Priv.js!(@support_modules, relative) - defp support_module!(relative, bindings) do Volt.Priv.js!(@support_modules, relative, bindings, rewrite_specifiers: @runtime_rewrites) end diff --git a/lib/volt/dev_server/client_tag.ex b/lib/volt/dev_server/client_tag.ex new file mode 100644 index 0000000..9e3f3eb --- /dev/null +++ b/lib/volt/dev_server/client_tag.ex @@ -0,0 +1,45 @@ +defmodule Volt.DevServer.ClientTag do + @moduledoc false + # Loads the HMR client as its own script in HTML pages, as Vite does. Modules + # also import the client, but a module that fails to compile stops its whole + # graph from running, and the client must still show the error overlay. + + alias Plug.Conn + + @client "/@volt/client.js" + @tag ~s() + + @spec register(Conn.t()) :: Conn.t() + def register(conn), do: Conn.register_before_send(conn, &inject/1) + + defp inject(%Conn{resp_body: body} = conn) when not is_nil(body) do + if html?(conn) do + html = IO.iodata_to_binary(body) + + if String.contains?(html, @client), + do: conn, + else: %{conn | resp_body: insert(html)} + else + conn + end + end + + defp inject(conn), do: conn + + defp html?(conn) do + conn + |> Conn.get_resp_header("content-type") + |> Enum.any?(&String.starts_with?(&1, "text/html")) + end + + defp insert(html) do + case :binary.match(html, "") do + {position, _length} -> + binary_part(html, 0, position) <> + @tag <> binary_part(html, position, byte_size(html) - position) + + :nomatch -> + html + end + end +end diff --git a/lib/volt/hmr.ex b/lib/volt/hmr.ex index 5f603d6..b312e81 100644 --- a/lib/volt/hmr.ex +++ b/lib/volt/hmr.ex @@ -44,9 +44,30 @@ defmodule Volt.HMR do @spec style_update(String.t()) :: :ok def style_update(path, opts \\ []), do: update(path, [:style], opts) - @doc "Broadcast an error payload for a source path." - @spec error(String.t(), term()) :: :ok - def error(path, reason, opts \\ []), do: broadcast(:error, %{path: path, reason: reason}, opts) + @doc """ + Report the errors for a source path and show them in connected browsers. + + `reason` may be `OXC.Diagnostic` maps, messages, exceptions, or any other term; + see `Volt.Dev.Error.entries/2`. The errors stay current until `clear_error/2`, + so browsers that connect later show them too. + """ + @spec error(String.t(), term(), keyword()) :: :ok + def error(path, reason, opts \\ []) do + session = Keyword.get(opts, :session, :default) + Volt.HMR.Errors.put(session, path, Volt.Dev.Error.entries(reason, file: path)) + broadcast_errors(session) + end + + @doc "Clear the errors reported for a source path, hiding the overlay when none remain." + @spec clear_error(String.t(), keyword()) :: :ok + def clear_error(path, opts \\ []) do + session = Keyword.get(opts, :session, :default) + if Volt.HMR.Errors.delete(session, path), do: broadcast_errors(session), else: :ok + end + + defp broadcast_errors(session) do + broadcast(:error, %{errors: Volt.HMR.Errors.list(session)}, session: session) + end @doc "Invalidate Volt's dev compilation state for a source file without broadcasting." @spec invalidate_file(String.t()) :: :ok diff --git a/lib/volt/hmr/errors.ex b/lib/volt/hmr/errors.ex new file mode 100644 index 0000000..e15c547 --- /dev/null +++ b/lib/volt/hmr/errors.ex @@ -0,0 +1,23 @@ +defmodule Volt.HMR.Errors do + @moduledoc false + # Current development errors per session and source path, so browsers that + # connect after a failure still show the overlay. + + @table :volt_hmr_errors + + def create_table, do: Volt.ETS.create_named_set(@table) + + def put(session, path, entries), do: Volt.ETS.put(@table, {{session, path}, entries}) + + @doc "Remove the errors for a path and return whether there were any." + def delete(session, path), do: :ets.take(@table, {session, path}) != [] + + def list(session) do + @table + |> :ets.match_object({{session, :_}, :_}) + |> Enum.sort() + |> Enum.flat_map(fn {_key, entries} -> entries end) + end + + def clear_session(session), do: Volt.ETS.clear_session(@table, session) +end diff --git a/lib/volt/hmr/message.ex b/lib/volt/hmr/message.ex index b2aecc7..d46557d 100644 --- a/lib/volt/hmr/message.ex +++ b/lib/volt/hmr/message.ex @@ -8,7 +8,8 @@ defmodule Volt.HMR.Message do ## Wire types * `update` — an HMR update payload (`path`, `changes`, optional `boundary`, `timestamp`) - * `error` — a build/runtime error to surface in the overlay + * `error` — the current build errors (`errors`, as `t:Volt.Dev.Error.entry/0` maps); + an empty list hides the overlay * `ping` — heartbeat sent by the browser client * `pong` — heartbeat reply from the server diff --git a/lib/volt/hmr/socket.ex b/lib/volt/hmr/socket.ex index 65a9c95..dda6748 100644 --- a/lib/volt/hmr/socket.ex +++ b/lib/volt/hmr/socket.ex @@ -14,7 +14,11 @@ defmodule Volt.HMR.Socket do def init(args) do session = Keyword.get(args, :session, :default) Registry.register(Volt.HMR.Registry, Volt.HMR.Channel.key(session), nil) - {:ok, %{}} + + case Volt.HMR.Errors.list(session) do + [] -> {:ok, %{}} + errors -> push(%Volt.HMR.Message{type: :error, payload: %{errors: errors}}, %{}) + end end # Heartbeat: the browser client sends a `{"type":"ping"}` JSON message diff --git a/lib/volt/watcher.ex b/lib/volt/watcher.ex index 7fdae3a..b732eef 100644 --- a/lib/volt/watcher.ex +++ b/lib/volt/watcher.ex @@ -164,17 +164,7 @@ defmodule Volt.Watcher do watch_ignored: watch_ignored } - if config[:tailwind] do - initial_tailwind_build( - tailwind_sources, - config[:tailwind_css], - tailwind_outdir, - tailwind_key, - tailwind_name, - config[:tailwind_runtime], - config[:tailwind_worker] - ) - end + if config[:tailwind], do: initial_tailwind_build(state) {:ok, refresh_tailwind_inputs(state)} end @@ -233,13 +223,22 @@ defmodule Volt.Watcher do defp watcher_sources(dirs), do: Enum.map(dirs, &%{base: &1, pattern: "**/*"}) - defp initial_tailwind_build(sources, css_path, outdir, key, name, runtime, worker) do - case build_tailwind(sources, css_path, outdir, key, name, runtime, worker) do + defp initial_tailwind_build(%{config: config} = state) do + case build_tailwind( + config.tailwind_sources, + config[:tailwind_css], + state.tailwind_outdir, + config.tailwind_key, + config.tailwind_name, + config[:tailwind_runtime], + config[:tailwind_worker] + ) do {:ok, css} -> Logger.debug("[Volt] Initial Tailwind build: #{byte_size(css)} bytes") {:error, reason} -> Logger.warning("[Volt] Initial Tailwind build failed: #{inspect(reason)}") + tailwind_error(reason, state) end end @@ -434,13 +433,14 @@ defmodule Volt.Watcher do state.tables || state.session ) + HMR.clear_error(path, session: state.session) changes = if css?, do: [:style], else: detect_changes(old_entry, result) broadcast_change(path, relative, changes, state) broadcast_css_dependents(css_dependents, state) broadcast_glob_dependents(path, state) {:error, reason} -> - HMR.broadcast(:error, %{path: relative, reason: reason}, session: state.session) + HMR.error(path, reason, session: state.session) end {:error, reason} when reason in [:enoent, :eacces, :eperm] -> @@ -448,11 +448,13 @@ defmodule Volt.Watcher do Volt.HMR.GlobGraph.remove(path, state.tables || state.session) if css?, do: Volt.HMR.StyleGraph.remove(path, state.tables || state.session) Volt.HMR.ModuleGraph.remove_file(path, state.tables || state.session) + HMR.clear_error(path, session: state.session) HMR.update(relative, [:full], session: state.session) broadcast_glob_dependents(path, state) {:error, reason} -> - HMR.broadcast(:error, %{path: relative, reason: inspect(reason)}, session: state.session) + message = "Could not read #{relative}: #{:file.format_error(reason)}" + HMR.error(path, message, session: state.session) end end @@ -603,6 +605,8 @@ defmodule Volt.Watcher do state.config[:tailwind_worker] ) do {:ok, css} -> + clear_tailwind_error(state) + if previous != {:ok, css} do HMR.broadcast(:update, %{path: state.config.tailwind_url, changes: [:style]}, session: state.session @@ -613,7 +617,7 @@ defmodule Volt.Watcher do :ok {:error, reason} -> - tailwind_error(reason, state.session) + tailwind_error(reason, state) end end @@ -641,6 +645,7 @@ defmodule Volt.Watcher do ] ) do Volt.Tailwind.Artifact.write(state.tailwind_outdir, state.config.tailwind_name, css) + clear_tailwind_error(state) HMR.broadcast(:update, %{path: state.config.tailwind_url, changes: [:style]}, session: state.session @@ -653,7 +658,7 @@ defmodule Volt.Watcher do :ok {:error, reason} -> - tailwind_error(reason, state.session) + tailwind_error(reason, state) end end @@ -668,11 +673,16 @@ defmodule Volt.Watcher do if worker, do: Volt.Tailwind.Worker.stylesheet(worker), else: {:error, :not_built} end - defp tailwind_error(reason, session) do - HMR.broadcast(:error, %{path: "tailwind", reason: inspect(reason)}, session: session) + defp tailwind_error(reason, state) do + HMR.error(tailwind_error_path(state), reason, session: state.session) {:error, reason} end + defp clear_tailwind_error(state), + do: HMR.clear_error(tailwind_error_path(state), session: state.session) + + defp tailwind_error_path(state), do: state.config[:tailwind_css] || "tailwind" + defp detect_changes(nil, _new), do: [:full] defp detect_changes(old_entry, new_result) do diff --git a/priv/ts/client/hmr.ts b/priv/ts/client/hmr.ts index 5310879..5764307 100644 --- a/priv/ts/client/hmr.ts +++ b/priv/ts/client/hmr.ts @@ -5,7 +5,7 @@ import { preserveHotData, type HotCallback } from './hot' -import { renderErrorOverlay } from './overlay' +import { clearErrorOverlay, renderErrorOverlay, type VoltError } from './overlay' import { removeStyle, updateStyle, updateStyles } from './styles' export { createHotContext, removeStyle, updateStyle } @@ -19,6 +19,7 @@ let reconnectTimer: ReturnType | undefined let heartbeatTimer: ReturnType | undefined let lastPongAt = 0 let reconnectAttempts = 0 +let connected = false const HEARTBEAT_INTERVAL = __VOLT_HEARTBEAT__ const PONG_GRACE = HEARTBEAT_INTERVAL * 2 @@ -30,6 +31,13 @@ function connect() { ws = new WebSocket(`${proto}//${location.host}/@volt/ws`) ws.onopen = () => { + // The server may have restarted while we were away, so the page can be stale. + if (connected) { + location.reload() + return + } + + connected = true console.log('[Volt] HMR connected') if (reconnectTimer) { @@ -72,7 +80,7 @@ function connect() { ) break case 'error': - showOverlay(payload.reason) + showErrors(payload.errors as VoltError[]) break case 'full-reload': location.reload() @@ -193,8 +201,12 @@ function importVersion(url: string, timestamp: number) { return import(/* @vite-ignore */ `${url}${url.includes('?') ? '&' : '?'}t=${timestamp}`) } -function showOverlay(reason: unknown) { - renderErrorOverlay(reason, { title: 'Build error', dismissible: true }) +function showErrors(errors: VoltError[]) { + if (errors.length > 0) { + renderErrorOverlay(errors, { title: 'Build error' }) + } else { + clearErrorOverlay() + } } connect() diff --git a/priv/ts/client/overlay.ts b/priv/ts/client/overlay.ts index 934bb07..c83a76c 100644 --- a/priv/ts/client/overlay.ts +++ b/priv/ts/client/overlay.ts @@ -1,35 +1,70 @@ const VOLT_ERROR_OVERLAY_ID = 'volt-error-overlay' const VOLT_ERROR_OVERLAY_STYLE = - 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.85);color:#ff6b6b;font:14px/1.6 monospace;padding:2em;white-space:pre-wrap;overflow:auto' + 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.85);color:#e8e8e8;font:14px/1.6 ui-monospace,monospace;padding:2em;overflow:auto;cursor:pointer' + +export type VoltError = { + message: string + file?: string | null + line?: number | null + column?: number | null + hint?: string | null + frame?: string | null +} type VoltErrorOverlayOptions = { title?: string - dismissible?: boolean } -export function renderErrorOverlay(reason: unknown, options: VoltErrorOverlayOptions = {}) { +export function renderErrorOverlay(errors: VoltError[], options: VoltErrorOverlayOptions = {}) { const title = options.title ?? 'Build error' - console.error(`[Volt] ${title}:\n${messageFor(reason)}`) + console.error(`[Volt] ${title}:\n${errors.map(errorText).join('\n\n')}`) if (typeof document === 'undefined') { return } - let overlay = document.getElementById(VOLT_ERROR_OVERLAY_ID) + clearErrorOverlay() + + const overlay = document.createElement('div') + overlay.id = VOLT_ERROR_OVERLAY_ID + overlay.style.cssText = VOLT_ERROR_OVERLAY_STYLE + overlay.onclick = () => overlay.remove() + overlay.append(block('div', `[Volt] ${title}`, 'color:#ff6b6b;font-weight:bold')) + + for (const error of errors) { + const entry = block('section', null, 'margin-top:1.5em') + const location = locationText(error) + + if (location) entry.append(block('div', location, 'color:#9aa0a6')) + entry.append(block('div', error.message, 'color:#ff6b6b;white-space:pre-wrap')) + if (error.frame) entry.append(block('pre', error.frame, 'margin:0.5em 0;color:#e8e8e8')) + if (error.hint) entry.append(block('div', error.hint, 'color:#8ab4f8;white-space:pre-wrap')) + + overlay.append(entry) + } + + document.body.appendChild(overlay) +} - if (!overlay) { - overlay = document.createElement('div') - overlay.id = VOLT_ERROR_OVERLAY_ID - document.body.appendChild(overlay) +export function clearErrorOverlay() { + if (typeof document !== 'undefined') { + document.getElementById(VOLT_ERROR_OVERLAY_ID)?.remove() } +} + +function block(tag: string, text: string | null, style: string) { + const element = document.createElement(tag) + element.style.cssText = style + if (text !== null) element.textContent = text + return element +} - overlay.style.cssText = options.dismissible - ? `${VOLT_ERROR_OVERLAY_STYLE};cursor:pointer` - : VOLT_ERROR_OVERLAY_STYLE - overlay.onclick = options.dismissible ? () => overlay?.remove() : null - overlay.textContent = `[Volt] ${title}:\n\n${messageFor(reason)}` +function locationText({ file, line, column }: VoltError) { + if (!file) return null + if (!line) return file + return column ? `${file}:${line}:${column}` : `${file}:${line}` } -function messageFor(reason: unknown) { - return typeof reason === 'string' ? reason : JSON.stringify(reason, null, 2) +function errorText(error: VoltError) { + return [locationText(error), error.message, error.frame, error.hint].filter(Boolean).join('\n') } diff --git a/test/support/volt/dev_server_case.ex b/test/support/volt/dev_server_case.ex index 0de24a2..3647926 100644 --- a/test/support/volt/dev_server_case.ex +++ b/test/support/volt/dev_server_case.ex @@ -98,8 +98,13 @@ defmodule Volt.TestSupport.DevServerCase do Volt.Cache.clear() Volt.HMR.StyleGraph.clear() Volt.HMR.ModuleGraph.clear() + Volt.HMR.Errors.clear_session(:default) + + on_exit(fn -> + Volt.HMR.Errors.clear_session(:default) + File.rm_rf!(@fixture_dir) + end) - on_exit(fn -> File.rm_rf!(@fixture_dir) end) :ok end diff --git a/test/volt/dev/error_test.exs b/test/volt/dev/error_test.exs new file mode 100644 index 0000000..17294cd --- /dev/null +++ b/test/volt/dev/error_test.exs @@ -0,0 +1,50 @@ +defmodule Volt.Dev.ErrorTest do + use ExUnit.Case, async: true + + alias Volt.Dev.Error + + @moduletag :tmp_dir + + test "locates diagnostics and frames the source line", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "app.ts") + File.write!(path, "const a = 1\nconst b = 2\nconst = ;\nconst c = 3\nconst d = 4\n") + {:error, diagnostics} = OXC.parse(File.read!(path), "app.ts") + + assert [%{file: file, line: 3, column: 7, message: message, frame: frame}] = + Error.entries(diagnostics, file: path) + + assert file == Path.relative_to_cwd(path) + assert message =~ "Unexpected token" + + assert frame == """ + 1 | const a = 1 + 2 | const b = 2 + > 3 | const = ; + | ^ + 4 | const c = 3\ + """ + end + + test "keeps a diagnostic's own file when it differs from the source path" do + diagnostic = %{message: "boom", file: "other.ts", position: {2, 1}, details: "try this"} + + assert [%{file: "other.ts", line: 2, column: 1, hint: "try this", frame: nil}] = + Error.entries([diagnostic], file: "app.ts") + end + + test "describes messages, exceptions, and other terms" do + assert [ + %{message: "plain", file: "app.ts", line: nil, frame: nil}, + %{message: "raised"}, + %{message: "{:unsupported, \".xyz\"}"} + ] = + Error.entries(["plain", RuntimeError.exception("raised"), {:unsupported, ".xyz"}], + file: "app.ts" + ) + end + + test "entries encode as JSON" do + {:error, diagnostics} = OXC.parse("const = ;", "app.ts") + assert {:ok, _json} = diagnostics |> Error.entries(file: "app.ts") |> Jason.encode() + end +end diff --git a/test/volt/dev_server/requests_test.exs b/test/volt/dev_server/requests_test.exs index 017185e..676be25 100644 --- a/test/volt/dev_server/requests_test.exs +++ b/test/volt/dev_server/requests_test.exs @@ -263,6 +263,30 @@ defmodule Volt.DevServer.RequestsTest do refute conn.halted end + test "loads the HMR client in pages the app renders" do + page = "x" + client = ~s() + + conn = + call_dev_server("/page") |> put_resp_content_type("text/html") |> send_resp(200, page) + + assert conn.resp_body == "x#{client}" + + loaded = "#{client}" + + conn = + call_dev_server("/page") |> put_resp_content_type("text/html") |> send_resp(200, loaded) + + assert conn.resp_body == loaded + + conn = + call_dev_server("/api") + |> put_resp_content_type("application/json") + |> send_resp(200, "{}") + + assert conn.resp_body == "{}" + end + test "serves static assets with correct MIME type" do File.write!(Path.join(@fixture_dir, "src/image.png"), "binary") conn = call_dev_server("/assets/image.png") @@ -331,12 +355,21 @@ defmodule Volt.DevServer.RequestsTest do end describe "error handling" do - test "returns 500 with error overlay for invalid source" do - File.write!(Path.join(@fixture_dir, "src/bad.ts"), "const = ;") + test "reports compile errors to the HMR client until the module compiles" do + path = Path.join(@fixture_dir, "src/bad.ts") + File.write!(path, "const = ;") + conn = call_dev_server("/assets/bad.ts") assert conn.status == 500 - assert conn.resp_body =~ "renderErrorOverlay" - assert conn.resp_body =~ "Compilation error" + assert conn.resp_body =~ "Could not compile" + + assert [%{line: 1, column: 7, frame: frame, file: file}] = Volt.HMR.Errors.list(:default) + assert file == Path.relative_to_cwd(path) + assert frame =~ "> 1 | const = ;" + + File.write!(path, "export const ok = 1") + assert call_dev_server("/assets/bad.ts").status == 200 + assert Volt.HMR.Errors.list(:default) == [] end end end diff --git a/test/volt/hmr/socket_test.exs b/test/volt/hmr/socket_test.exs index 15d0a48..6b6c665 100644 --- a/test/volt/hmr/socket_test.exs +++ b/test/volt/hmr/socket_test.exs @@ -1,12 +1,26 @@ defmodule Volt.HMR.SocketTest do use ExUnit.Case, async: false + setup do + Volt.HMR.Errors.clear_session(:default) + on_exit(fn -> Volt.HMR.Errors.clear_session(:default) end) + end + describe "init/1" do test "registers with registry" do {:ok, _state} = Volt.HMR.Socket.init(nil) me = self() assert {me, nil} in Registry.lookup(Volt.HMR.Registry, :clients) end + + test "sends current errors to a new client" do + Volt.HMR.error("app.ts", "boom") + + {:push, {:text, json}, _state} = Volt.HMR.Socket.init(nil) + + assert %{"type" => "error", "payload" => %{"errors" => [%{"message" => "boom"}]}} = + Jason.decode!(json) + end end describe "handle_info/2" do diff --git a/test/volt/hmr_test.exs b/test/volt/hmr_test.exs index c3769a5..a5603e2 100644 --- a/test/volt/hmr_test.exs +++ b/test/volt/hmr_test.exs @@ -3,7 +3,8 @@ defmodule Volt.HMRTest do setup do Registry.register(Volt.HMR.Registry, :clients, nil) - :ok + Volt.HMR.Errors.clear_session(:default) + on_exit(fn -> Volt.HMR.Errors.clear_session(:default) end) end test "scoped broadcasts do not reach other sessions or default subscribers" do @@ -68,9 +69,18 @@ defmodule Volt.HMRTest do assert_receive {:volt_hmr, :update, %{path: "app.css", changes: ["style"]}} end - test "error broadcasts error payload" do + test "error broadcasts the current errors until they are cleared" do assert :ok = Volt.HMR.error("index.html", "boom") - assert_receive {:volt_hmr, :error, %{path: "index.html", reason: "boom"}} + assert_receive {:volt_hmr, :error, %{errors: [%{message: "boom", file: "index.html"}]}} + + assert :ok = Volt.HMR.error("app.ts", "bang") + assert_receive {:volt_hmr, :error, %{errors: [%{message: "bang"}, %{message: "boom"}]}} + + assert :ok = Volt.HMR.clear_error("index.html") + assert_receive {:volt_hmr, :error, %{errors: [%{message: "bang"}]}} + + assert :ok = Volt.HMR.clear_error("index.html") + refute_receive {:volt_hmr, :error, _payload} end test "invalidate_file evicts dev compilation state" do From 94fe75266c8df9bc613ef747a4f206ae624cc602 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 30 Sep 2026 11:17:49 +0400 Subject: [PATCH 067/128] Update vize to 0.15 --- CHANGELOG.md | 2 +- mix.exs | 2 +- mix.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e18ecb6..ea8de71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ ### Changed -- Require `oxc` 0.18, `quickbeam` 0.11.2, and `json_codec` 0.3. +- Require `oxc` 0.18, `vize` 0.15, `quickbeam` 0.11.2, and `json_codec` 0.3. ### Fixed diff --git a/mix.exs b/mix.exs index 199e9f9..9607314 100644 --- a/mix.exs +++ b/mix.exs @@ -39,7 +39,7 @@ defmodule Volt.MixProject do {:reach, "~> 2.6.1", only: [:dev, :test], runtime: false}, {:glob_ex, "~> 0.1.12"}, {:oxc, "~> 0.18.0"}, - {:vize, "~> 0.14.2"}, + {:vize, "~> 0.15.0"}, {:oxide_ex, "~> 0.2.2"}, {:quickbeam, "~> 0.11.2"}, {:dotenvy, "~> 1.1"}, diff --git a/mix.lock b/mix.lock index 261efcb..1560c8e 100644 --- a/mix.lock +++ b/mix.lock @@ -62,7 +62,7 @@ "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, "varint": {:hex, :varint, "1.6.0", "7bced828599b2eb84491a9f067f8a5a67fc35a946d3b7582278083c7fd434b00", [:mix], [], "hexpm", "2b4f4a20650aeebe993a70b03bb99571bcbddc27c361322c28b586d8a772ce4e"}, - "vize": {:hex, :vize, "0.14.2", "4baf40c18e00963ce802c03ebb664feaccd8558d7ec62aa43fb07ad8485d0208", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8336744616bc27ce4edc7448cda6f0b8a481cbec9035d3c4d6bf3229be91386d"}, + "vize": {:hex, :vize, "0.15.0", "f13eaecaa6a59c3314e374febc6b21e127560e08836f5d3e8edc4282412d182e", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "e1f2021c1e43d1f7530081dbf00ea93f6e55ea6928d10371a64e1214605316a0"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "zig_get": {:hex, :zig_get, "0.15.2", "a6ccaa894213839ba95615bf9be2b2c9268e37ab9547a2344830202cfd6d7cc0", [:mix], [], "hexpm", "e6b0028f2d5a8da791ff8037deff5b492784017d8d241e598377a24bd765f56f"}, From a15f46e63d19560c8231752b4e90a88c986b214f Mon Sep 17 00:00:00 2001 From: Roman Kurakin Date: Tue, 15 Sep 2026 21:39:27 +0500 Subject: [PATCH 068/128] fix: apply lint categories to type-aware checks Rebased onto Volt 0.19: batches group files by the rules OXC.Lint.type_aware_rules/1 resolves, without a category list here. Co-authored-by: Danila Poyarkov --- CHANGELOG.md | 3 +- guides/features/formatting-and-linting.md | 2 + lib/volt/js/check.ex | 36 ++++++------- mix.exs | 2 +- test/mix/tasks/volt/js/check_test.exs | 64 +++++++++++++++++++++-- 5 files changed, 79 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea8de71..93d4b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,13 +18,14 @@ ### Changed -- Require `oxc` 0.18, `vize` 0.15, `quickbeam` 0.11.2, and `json_codec` 0.3. +- Require `oxc` 0.18.1, `vize` 0.15, `quickbeam` 0.11.2, and `json_codec` 0.3. ### Fixed - Show the error overlay when a page loads with a module that fails to compile. The client previously loaded only through the failing module graph. - Hide the overlay once errors are fixed, and report Tailwind build failures, including at startup. - Reload the page after the HMR connection returns, since the server may have restarted. +- Type-aware checks run the type-aware rules that lint categories such as `"correctness"` select, honoring individual rule settings and per-file overrides ([#40](https://github.com/elixir-volt/volt/issues/40)). - Type-aware checks extract single-file component scripts with the configured Volt plugins instead of the oxlint plugin names. ## 0.18.1 - 2026-09-15 diff --git a/guides/features/formatting-and-linting.md b/guides/features/formatting-and-linting.md index d195a0a..bd63492 100644 --- a/guides/features/formatting-and-linting.md +++ b/guides/features/formatting-and-linting.md @@ -140,3 +140,5 @@ config :volt, :lint, Volt keeps the Oxlint-style rule shape: configure normal and type-aware rules together under `:rules`. When `--type-aware` is enabled, Volt still runs the normal syntax lint path and also invokes `tsgolint` for supported semantic TypeScript rules. Exits with non-zero status on issues. + +With `--type-aware`, categories also select type-aware rules from the enabled plugins. For example, `"correctness" => :deny` with `plugins: ["typescript"]` runs `typescript/no-floating-promises`. Individual rule settings override their categories, and per-file overrides apply before categories are expanded. Configurations without categories run only the type-aware rules they name. `--type-check` independently enables TypeScript compiler diagnostics. diff --git a/lib/volt/js/check.ex b/lib/volt/js/check.ex index 6a9a21b..88b6631 100644 --- a/lib/volt/js/check.ex +++ b/lib/volt/js/check.ex @@ -82,23 +82,23 @@ defmodule Volt.JS.Check do files |> Enum.group_by(fn file -> original = Map.get(source_files, Path.expand(file), file) - - lint_config - |> Volt.JS.Lint.Config.options(original) - |> Keyword.fetch!(:rules) - |> typescript_rules() + lint_config |> Volt.JS.Lint.Config.options(original) |> OXC.Lint.type_aware_rules() end) |> Enum.sort_by(fn {rules, _files} -> rules end) - |> Enum.flat_map(fn {rules, batch} -> - case run_type_aware_lint(batch, Keyword.put(common_opts, :rules, rules)) do - {:ok, diagnostics} -> - Enum.map(diagnostics, fn diagnostic -> - diagnostic |> restore_sfc_file(source_files) |> promote_type_check_diagnostic(opts) - end) - - {:error, errors} -> - errors - end + |> Enum.flat_map(fn + {{:ok, rules}, batch} -> + case run_type_aware_lint(batch, Keyword.put(common_opts, :rules, rules)) do + {:ok, diagnostics} -> + Enum.map(diagnostics, fn diagnostic -> + diagnostic |> restore_sfc_file(source_files) |> promote_type_check_diagnostic(opts) + end) + + {:error, errors} -> + errors + end + + {{:error, errors}, _batch} -> + errors end) end @@ -179,12 +179,6 @@ defmodule Volt.JS.Check do end) end - defp typescript_rules(rules) do - Map.filter(rules, fn {rule, _config} -> - String.starts_with?(to_string(rule), "typescript/") - end) - end - defp type_aware_options(config) do config |> Keyword.take([:tsgolint, :fix, :fix_suggestions, :cwd]) diff --git a/mix.exs b/mix.exs index 9607314..d0f6f84 100644 --- a/mix.exs +++ b/mix.exs @@ -38,7 +38,7 @@ defmodule Volt.MixProject do [ {:reach, "~> 2.6.1", only: [:dev, :test], runtime: false}, {:glob_ex, "~> 0.1.12"}, - {:oxc, "~> 0.18.0"}, + {:oxc, "~> 0.18.1"}, {:vize, "~> 0.15.0"}, {:oxide_ex, "~> 0.2.2"}, {:quickbeam, "~> 0.11.2"}, diff --git a/test/mix/tasks/volt/js/check_test.exs b/test/mix/tasks/volt/js/check_test.exs index 88dd353..99c6e30 100644 --- a/test/mix/tasks/volt/js/check_test.exs +++ b/test/mix/tasks/volt/js/check_test.exs @@ -78,6 +78,54 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do assert output =~ "typescript/no-floating-promises" end + test "category-only configuration submits semantic rules to tsgolint" do + file = Path.join(@tmp_dir, "typed.ts") + File.write!(file, "Promise.resolve(1);\n") + + Application.put_env(:volt, :lint, + tsgolint: fake_tsgolint!(@tmp_dir), + plugins: ["typescript"], + rules: %{"correctness" => :deny} + ) + + diagnostics = Volt.JS.Check.lint([file], type_aware: true) + + assert Enum.any?( + diagnostics, + &(&1.rule == "typescript/no-floating-promises" and &1.severity == :error) + ) + end + + test "category expansion honors per-file semantic rule exclusions" do + first = Path.join(@tmp_dir, "first.ts") + second = Path.join(@tmp_dir, "second.ts") + Enum.each([first, second], &File.write!(&1, "Promise.resolve(1);\n")) + payload_path = Path.join(@tmp_dir, "categories.jsonl") + + tsgolint = + fake_executable!(@tmp_dir, "tsgolint-categories", """ + input = IO.binread(:stdio, :eof) + File.write!(#{inspect(payload_path)}, [input, "\\n"], [:append]) + """) + + Application.put_env(:volt, :lint, + root: @tmp_dir, + tsgolint: tsgolint, + plugins: ["typescript"], + rules: %{"correctness" => :deny}, + overrides: [%{files: ["second.ts"], rules: %{"typescript/no-floating-promises" => :allow}}] + ) + + assert [] = Volt.JS.Check.lint([first, second], type_aware: true) + batches = payload_path |> File.stream!() |> Enum.map(&Jason.decode!/1) + configs = Enum.flat_map(batches, & &1["configs"]) + + for {file, selected} <- [{first, true}, {second, false}] do + config = Enum.find(configs, &(Path.expand(file) in &1["file_paths"])) + assert Enum.any?(config["rules"], &(&1["name"] == "no-floating-promises")) == selected + end + end + test "type-check diagnostics are promoted to errors" do diagnostic = %{ rule: "typescript/TS2322", @@ -110,7 +158,13 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do end) payload = @tmp_dir |> Path.join("payload.json") |> File.read!() |> Jason.decode!() - assert [%{"rules" => [%{"name" => "no-floating-promises"}]}] = payload["configs"] + assert [%{"rules" => rules}] = payload["configs"] + assert Enum.any?(rules, &(&1["name"] == "no-floating-promises")) + + refute Enum.any?( + rules, + &(&1["name"] in ["correctness", "suspicious", "consistent-type-imports"]) + ) end test "type-aware check submits framework single-file component scripts as virtual files" do @@ -176,7 +230,7 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do Application.put_env(:volt, :lint, root: @tmp_dir, tsgolint: tsgolint, - rules: %{"typescript/no-floating-promises" => :deny}, + rules: %{"correctness" => :deny, "typescript/no-floating-promises" => :deny}, overrides: [ %{files: ["**/*.{vue,svelte}"], rules: %{"typescript/no-floating-promises" => :warn}}, %{files: ["**/*.script0.ts"], rules: %{"typescript/no-floating-promises" => :allow}} @@ -196,9 +250,9 @@ defmodule Mix.Tasks.Volt.Js.CheckTest do configs = Enum.flat_map(batches, & &1["configs"]) assert Enum.sort(Enum.map(configs, &length(&1["file_paths"]))) == [1, 2] - assert configs |> Enum.flat_map(& &1["rules"]) |> Enum.map(& &1["name"]) |> Enum.uniq() == [ - "no-floating-promises" - ] + names = configs |> Enum.flat_map(& &1["rules"]) |> Enum.map(& &1["name"]) + assert "no-floating-promises" in names + refute "correctness" in names assert Enum.find(diagnostics, &(&1.file == Path.expand(app))).severity == :error assert Enum.find(diagnostics, &(&1.file == vue)).severity == :warning From 16e9361d395dbd33b5b3bc0dd1b65ff7c1a6806f Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 30 Sep 2026 11:08:50 +0400 Subject: [PATCH 069/128] Update oxc to 0.18.1 --- mix.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.lock b/mix.lock index 1560c8e..3e5605d 100644 --- a/mix.lock +++ b/mix.lock @@ -41,7 +41,7 @@ "npm": {:hex, :npm, "0.7.6", "de8ca3dd26a40fac7ab8a17680088b8d55bab0e0294be9770b029c276361482f", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:npm_semver, "~> 0.1.0", [hex: :npm_semver, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "e0a1ce77e3e49ec108e095aba133bd315e86da298dba26b142a105972141d9ef"}, "npm_semver": {:hex, :npm_semver, "0.1.0", "3ab2c2a151d8c87c364209b2ca1a4fd2ab98507ed61afbd1ea12c1826e67200a", [:mix], [{:hex_solver, "~> 0.2", [hex: :hex_solver, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "77afbc4c523c19a572325190bc4c968ec027e1c6ef8538bcddacf835966072fa"}, "owl": {:hex, :owl, "0.13.1", "1ec4a5dea170465f0e90c502c203079224516bc0cbd599281c8667b3c6ef8848", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "351e768af8f2edc575cdaab1a5a2f6d6381be591758a026c701c703145508a0c"}, - "oxc": {:hex, :oxc, "0.18.0", "0b22ae1ac62f6e985458aae53bb0020756bd790ed07bcda1a89168b266025415", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "121495bbbaba0807433653d4446cb9ed68bc7db35e6f11a9c82da56f89bcaa6c"}, + "oxc": {:hex, :oxc, "0.18.1", "9fb4cd232ee2143364e86a021992958b1aecf07583432ebb3319386e2f6ce6c0", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "2653908736b6064c8363415280472bc687ad3028ee7f1441cb6ac458fc8e9a62"}, "oxide_ex": {:hex, :oxide_ex, "0.2.2", "063e0d2899e81632b7efc7849eb1dd89a7a623290a075c446b43bef14cf5795e", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "577a2fbd97c46904a31143bb700cdb374105085cfd1f7605416bb6c012cb7f60"}, "pegasus": {:hex, :pegasus, "0.2.6", "b4af6522326fbb2ffd1bb706e78ec05854fbcb4b03a7fe08f8db2e9de1d0be67", [:mix], [{:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0ac159f0ccab7967cf90208327cc8a35788874814c8d78e19d47104d3fc049b9"}, "pi_bridge": {:hex, :pi_bridge, "0.6.21", "f1a63da5f49fef4f6b35e98edd5400cbd64a49fb8bda84be41b7e91f3deb0b89", [:mix], [{:bandit, "~> 1.8", [hex: :bandit, repo: "hexpm", optional: false]}, {:dune, "~> 0.3", [hex: :dune, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ex_ast, "~> 0.12", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:json_codec, "~> 0.1.3", [hex: :json_codec, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:quackdb, "~> 0.5.4", [hex: :quackdb, repo: "hexpm", optional: false]}, {:reach, "~> 2.6", [hex: :reach, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:req_llm, "~> 1.6", [hex: :req_llm, repo: "hexpm", optional: true]}], "hexpm", "b226fb60965a8e928186af3c3b96fe8f0fefd8c6cc7be679d6e169ba84105de9"}, From 4ebc0bb9617ab47a6745a52ed1a464d72850e8a9 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 30 Sep 2026 11:38:47 +0400 Subject: [PATCH 070/128] Show the error overlay as a solid panel over a dimmed page The page showed through the translucent backdrop and competed with the error text. --- examples/vanilla/mix.lock | 2 +- priv/ts/client/overlay.ts | 40 +++++++++++++++++++++++++++++++-------- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/examples/vanilla/mix.lock b/examples/vanilla/mix.lock index faa11b0..3458870 100644 --- a/examples/vanilla/mix.lock +++ b/examples/vanilla/mix.lock @@ -38,7 +38,7 @@ "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, "varint": {:hex, :varint, "1.6.0", "7bced828599b2eb84491a9f067f8a5a67fc35a946d3b7582278083c7fd434b00", [:mix], [], "hexpm", "2b4f4a20650aeebe993a70b03bb99571bcbddc27c361322c28b586d8a772ce4e"}, - "vize": {:hex, :vize, "0.14.2", "4baf40c18e00963ce802c03ebb664feaccd8558d7ec62aa43fb07ad8485d0208", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "8336744616bc27ce4edc7448cda6f0b8a481cbec9035d3c4d6bf3229be91386d"}, + "vize": {:hex, :vize, "0.15.0", "f13eaecaa6a59c3314e374febc6b21e127560e08836f5d3e8edc4282412d182e", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "e1f2021c1e43d1f7530081dbf00ea93f6e55ea6928d10371a64e1214605316a0"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "zigler_precompiled": {:hex, :zigler_precompiled, "0.1.6", "1c5c889ff30164eb340e599ae987b6c51c3ba0654117ec7a68a6b83d4df39310", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:zigler, "~> 0.13", [hex: :zigler, repo: "hexpm", optional: true]}], "hexpm", "5754d342533b2fb8e767c0ef216ee13c928e17f317cac7d6a01a0972ba68ce4a"}, diff --git a/priv/ts/client/overlay.ts b/priv/ts/client/overlay.ts index c83a76c..3051e56 100644 --- a/priv/ts/client/overlay.ts +++ b/priv/ts/client/overlay.ts @@ -1,6 +1,8 @@ const VOLT_ERROR_OVERLAY_ID = 'volt-error-overlay' const VOLT_ERROR_OVERLAY_STYLE = - 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.85);color:#e8e8e8;font:14px/1.6 ui-monospace,monospace;padding:2em;overflow:auto;cursor:pointer' + 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.66);padding:6vh 16px;overflow:auto' +const VOLT_ERROR_PANEL_STYLE = + 'box-sizing:border-box;max-width:960px;margin:0 auto;padding:24px 28px;background:#181818;border-top:6px solid #ff5555;border-radius:6px;box-shadow:0 12px 32px rgba(0,0,0,0.5);color:#e8e8e8;font:14px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace' export type VoltError = { message: string @@ -28,21 +30,43 @@ export function renderErrorOverlay(errors: VoltError[], options: VoltErrorOverla const overlay = document.createElement('div') overlay.id = VOLT_ERROR_OVERLAY_ID overlay.style.cssText = VOLT_ERROR_OVERLAY_STYLE - overlay.onclick = () => overlay.remove() - overlay.append(block('div', `[Volt] ${title}`, 'color:#ff6b6b;font-weight:bold')) + overlay.onclick = (event) => { + if (event.target === overlay) overlay.remove() + } + + const panel = block('div', null, VOLT_ERROR_PANEL_STYLE) + panel.append(block('div', `[Volt] ${title}`, 'color:#ff5555;font-weight:bold')) for (const error of errors) { - const entry = block('section', null, 'margin-top:1.5em') + const entry = block('section', null, 'margin-top:1.25em') const location = locationText(error) if (location) entry.append(block('div', location, 'color:#9aa0a6')) - entry.append(block('div', error.message, 'color:#ff6b6b;white-space:pre-wrap')) - if (error.frame) entry.append(block('pre', error.frame, 'margin:0.5em 0;color:#e8e8e8')) - if (error.hint) entry.append(block('div', error.hint, 'color:#8ab4f8;white-space:pre-wrap')) + entry.append(block('div', error.message, 'color:#ff8080;white-space:pre-wrap')) + if (error.frame) { + entry.append( + block( + 'pre', + error.frame, + 'margin:0.75em 0 0;padding:12px 16px;background:#0f0f0f;border-radius:4px;overflow-x:auto;color:#e8e8e8;font:inherit' + ) + ) + } + if (error.hint) { + entry.append(block('div', error.hint, 'margin-top:0.5em;color:#8ab4f8;white-space:pre-wrap')) + } - overlay.append(entry) + panel.append(entry) } + panel.append( + block( + 'div', + 'Fix the error to dismiss this overlay, or click outside it.', + 'margin-top:1.5em;color:#777;font-size:12px' + ) + ) + overlay.append(panel) document.body.appendChild(overlay) } From 093dd4bf1909b62233574ce9e24ce377d68cf298 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 30 Sep 2026 11:51:11 +0400 Subject: [PATCH 071/128] Highlight the error overlay's source frame with optional Lumis With Lumis and a parser package for the file's language, error entries carry frame_html: the whole file is highlighted so multi-line tokens keep their colors, and the frame keeps Volt's gutter, marker, and caret. Without them the frame stays plain text. --- CHANGELOG.md | 1 + examples/vanilla/mix.exs | 4 +- examples/vanilla/mix.lock | 2 + guides/features/features.md | 11 ++++- lib/volt/dev/error.ex | 91 ++++++++++++++++++++++++++++-------- mix.exs | 2 + mix.lock | 2 + priv/ts/client/overlay.ts | 14 +++--- test/volt/dev/error_test.exs | 22 +++++++++ 9 files changed, 122 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea8de71..4b17b0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Added - The error overlay shows each error's file, line, column, source frame, and hint. +- Highlight the error overlay's source frame with the optional `lumis` dependency and its parser packages, such as `lumis_wasm_typescript`. - `Volt.HMR.clear_error/2` hides errors reported with `Volt.HMR.error/3`. Errors stay current until cleared, so browsers that connect later show them. - The dev server adds the HMR client to HTML pages the app renders. diff --git a/examples/vanilla/mix.exs b/examples/vanilla/mix.exs index 4c47cac..9f98dbc 100644 --- a/examples/vanilla/mix.exs +++ b/examples/vanilla/mix.exs @@ -36,7 +36,9 @@ defmodule VanillaExample.MixProject do {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, {:bandit, ">= 1.12.5 and < 2.0.0"}, - {:volt, path: "../..", override: true} + {:volt, path: "../..", override: true}, + {:lumis, "~> 0.10", only: :dev}, + {:lumis_wasm_typescript, "~> 0.26", only: :dev} ] end diff --git a/examples/vanilla/mix.lock b/examples/vanilla/mix.lock index 3458870..c74d820 100644 --- a/examples/vanilla/mix.lock +++ b/examples/vanilla/mix.lock @@ -12,6 +12,8 @@ "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.3.1", "81f738e212308c85344e11da00af6d8b52bd486224ee24c1f51b95df0604aeae", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:json_spec, "~> 1.2", [hex: :json_spec, repo: "hexpm", optional: false]}], "hexpm", "dffea3198d3f1fe9a39aa176ab0d6988b8c80dd85a67e913b323d4ec7c57ca9d"}, "json_spec": {:hex, :json_spec, "1.2.0", "c67f73cdc4166e955b2f1095d3f44dd78c197337038f1a5301fb526b6c87ab04", [:mix], [], "hexpm", "3639961babad7d6bc45f3a2d96a49c18ec70614761bb84ce58b64e9fad9354de"}, + "lumis": {:hex, :lumis, "0.10.0", "4b0b773447f7dace8be7b0c93abe4d24e9b7c81034ac2757003f3ac4b7ac3450", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "5e3845defa32871a404d6a51ad2d8d3c423077f6f80149e632511f26460f27dc"}, + "lumis_wasm_typescript": {:hex, :lumis_wasm_typescript, "0.26.2", "6f6972ba900860bfa5b601333a216faa24a9e1f59dac84b2beae3911941b006b", [:mix], [], "hexpm", "277acc22eb48133299e137ba5f127bf635409192dd5f5c6f5839e52fd7c0bc72"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.11.0", "a713551624815c0435237b93d90ea8b9b14254690c66f732d0ef8930f76ff1d9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "c6279ba2d6aa3a383a1d4cfbe7b59f42e6efd400f58d8e2acfeac48a438693ab"}, "mint_web_socket": {:hex, :mint_web_socket, "1.0.6", "5ffcf350df5b90f2d7a04adf877165228804993714592512374218d4679e325a", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "0c360e9012413f1c115a63532601eb5d63731aab7010949178769760686c1698"}, diff --git a/guides/features/features.md b/guides/features/features.md index 7920118..1867953 100644 --- a/guides/features/features.md +++ b/guides/features/features.md @@ -210,7 +210,16 @@ In development, browser `console.log`, `console.warn`, and `console.error` calls ## Error Overlay -Compilation errors in development are displayed as a full-screen browser overlay with each error's file, line, column, and source frame. It appears on page load as well as during HMR, dismisses on click, and clears automatically when the error is fixed. +Compilation errors in development are displayed as a browser overlay with each error's file, line, column, and source frame. It appears on page load as well as during HMR, dismisses on click outside it, and clears automatically when the error is fixed. + +Add [Lumis](https://hex.pm/packages/lumis) and parser packages for your languages to highlight the source frame: + +```elixir +{:lumis, "~> 0.10", only: :dev}, +{:lumis_wasm_typescript, "~> 0.26", only: :dev} +``` + +Without them, or for a language without an installed parser, the frame is plain text. ## Plugins diff --git a/lib/volt/dev/error.ex b/lib/volt/dev/error.ex index 673bfdb..45542ff 100644 --- a/lib/volt/dev/error.ex +++ b/lib/volt/dev/error.ex @@ -5,15 +5,22 @@ defmodule Volt.Dev.Error do Build errors arrive as `OXC.Diagnostic` maps, compiler messages, exceptions, or arbitrary terms. `entries/2` turns any of them into maps the browser overlay renders, with a source frame when the file and line are known. + + With the optional `:lumis` dependency and a Lumis parser package for the file's + language, such as `:lumis_wasm_typescript`, entries also carry `frame_html`, the + frame with syntax highlighting. """ + @theme "github_dark" + @type entry :: %{ message: String.t(), file: String.t() | nil, line: pos_integer() | nil, column: pos_integer() | nil, hint: String.t() | nil, - frame: String.t() | nil + frame: String.t() | nil, + frame_html: String.t() | nil } @doc """ @@ -46,6 +53,7 @@ defmodule Volt.Dev.Error do file = source_file(fields[:file], opts[:file]) line = fields[:line] column = fields[:column] + {frame, frame_html} = frames(file, line, column) %{ message: message, @@ -53,7 +61,8 @@ defmodule Volt.Dev.Error do line: line, column: column, hint: fields[:hint], - frame: frame(file, line, column) + frame: frame, + frame_html: frame_html } end @@ -68,37 +77,81 @@ defmodule Volt.Dev.Error do defp position(line) when is_integer(line) and line > 0, do: {line, nil} defp position(_position), do: {nil, nil} - defp frame(file, line, column) when is_binary(file) and is_integer(line) do + defp frames(file, line, column) when is_binary(file) and is_integer(line) do with {:ok, source} <- File.read(file), lines = String.split(source, ~r/\r?\n/), true <- line <= length(lines) do first = max(line - 2, 1) last = min(line + 1, length(lines)) width = last |> Integer.to_string() |> byte_size() + frame = %{first: first, line: line, column: column, width: width} + texts = Enum.slice(lines, (first - 1)..(last - 1)//1) - lines - |> Enum.slice((first - 1)..(last - 1)//1) - |> Enum.with_index(first) - |> Enum.flat_map(&frame_rows(&1, line, column, width)) - |> Enum.join("\n") + html = + with [_ | _] = highlighted <- highlight(file, source, first..last//1) do + # Lumis leaves out a trailing empty line. + highlighted = highlighted ++ List.duplicate("", length(texts) - length(highlighted)) + render(frame, highlighted, &html_gutter/1) + end + + {render(frame, texts, & &1), html} else - _ -> nil + _ -> {nil, nil} end end - defp frame(_file, _line, _column), do: nil + defp frames(_file, _line, _column), do: {nil, nil} + + defp render(frame, texts, gutter) do + texts + |> Enum.with_index(frame.first) + |> Enum.flat_map(fn {text, number} -> + marker = if number == frame.line, do: ">", else: " " + padded = number |> Integer.to_string() |> String.pad_leading(frame.width) + row = gutter.("#{marker} #{padded} | ") <> text + + if number == frame.line and frame.column do + blank = String.duplicate(" ", frame.width) + [row, gutter.(" #{blank} | #{String.duplicate(" ", frame.column - 1)}^")] + else + [row] + end + end) + |> Enum.join("\n") + end - defp frame_rows({text, number}, line, column, width) do - gutter = number |> Integer.to_string() |> String.pad_leading(width) + # The error line's marker and caret stand out; other gutters are dim. + defp html_gutter(gutter) do + color = if String.contains?(gutter, [">", "^"]), do: "#ff5555", else: "#6e7681" + ~s(#{String.replace(gutter, ">", ">")}) + end - if number == line do - row = "> #{gutter} | #{text}" + if Code.ensure_loaded?(Lumis) do + # Highlights the whole file, so tokens that span lines keep their colors, and + # keeps the lines in `range`. Nil when no Lumis parser covers the language. + defp highlight(file, source, range) do + language = Lumis.Languages.guess(file, source) + formatter = {:html_inline, language: language, theme: @theme} + + with true <- language != "plaintext", + :ok <- Lumis.Languages.load(language), + {:ok, html} <- Lumis.highlight(source, formatter: formatter) do + html + |> String.split("\n") + |> Enum.slice((range.first - 1)..(range.last - 1)//1) + |> Enum.map(&line_html/1) + else + _ -> nil + end + end - if column, - do: [row, " #{String.duplicate(" ", width)} | #{String.duplicate(" ", column - 1)}^"], - else: [row] - else - [" #{gutter} | #{text}"] + defp line_html(line) do + case Regex.run(~r{]*>(.*)}, line, capture: :all_but_first) do + [html] -> html + nil -> "" + end end + else + defp highlight(_file, _source, _range), do: nil end end diff --git a/mix.exs b/mix.exs index 9607314..36d07e4 100644 --- a/mix.exs +++ b/mix.exs @@ -50,6 +50,8 @@ defmodule Volt.MixProject do {:file_system, "~> 1.0"}, {:jason, "~> 1.4"}, {:json_codec, "~> 0.3"}, + {:lumis, "~> 0.10", optional: true}, + {:lumis_wasm_typescript, "~> 0.26", only: :test}, {:igniter, ">= 0.8.4 and < 1.0.0", optional: true}, {:npm, "~> 0.7.6"}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index 1560c8e..22b736d 100644 --- a/mix.lock +++ b/mix.lock @@ -27,6 +27,8 @@ "json_codec": {:hex, :json_codec, "0.3.1", "81f738e212308c85344e11da00af6d8b52bd486224ee24c1f51b95df0604aeae", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:json_spec, "~> 1.2", [hex: :json_spec, repo: "hexpm", optional: false]}], "hexpm", "dffea3198d3f1fe9a39aa176ab0d6988b8c80dd85a67e913b323d4ec7c57ca9d"}, "json_spec": {:hex, :json_spec, "1.2.0", "c67f73cdc4166e955b2f1095d3f44dd78c197337038f1a5301fb526b6c87ab04", [:mix], [], "hexpm", "3639961babad7d6bc45f3a2d96a49c18ec70614761bb84ce58b64e9fad9354de"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, + "lumis": {:hex, :lumis, "0.10.0", "4b0b773447f7dace8be7b0c93abe4d24e9b7c81034ac2757003f3ac4b7ac3450", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "5e3845defa32871a404d6a51ad2d8d3c423077f6f80149e632511f26460f27dc"}, + "lumis_wasm_typescript": {:hex, :lumis_wasm_typescript, "0.26.2", "6f6972ba900860bfa5b601333a216faa24a9e1f59dac84b2beae3911941b006b", [:mix], [], "hexpm", "277acc22eb48133299e137ba5f127bf635409192dd5f5c6f5839e52fd7c0bc72"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, diff --git a/priv/ts/client/overlay.ts b/priv/ts/client/overlay.ts index 3051e56..44f6542 100644 --- a/priv/ts/client/overlay.ts +++ b/priv/ts/client/overlay.ts @@ -11,6 +11,7 @@ export type VoltError = { column?: number | null hint?: string | null frame?: string | null + frame_html?: string | null } type VoltErrorOverlayOptions = { @@ -44,13 +45,14 @@ export function renderErrorOverlay(errors: VoltError[], options: VoltErrorOverla if (location) entry.append(block('div', location, 'color:#9aa0a6')) entry.append(block('div', error.message, 'color:#ff8080;white-space:pre-wrap')) if (error.frame) { - entry.append( - block( - 'pre', - error.frame, - 'margin:0.75em 0 0;padding:12px 16px;background:#0f0f0f;border-radius:4px;overflow-x:auto;color:#e8e8e8;font:inherit' - ) + const frame = block( + 'pre', + error.frame, + 'margin:0.75em 0 0;padding:12px 16px;background:#0d1117;border-radius:4px;overflow-x:auto;color:#e6edf3;font:inherit' ) + // Built by the dev server from escaped, syntax-highlighted source. + if (error.frame_html) frame.innerHTML = error.frame_html + entry.append(frame) } if (error.hint) { entry.append(block('div', error.hint, 'margin-top:0.5em;color:#8ab4f8;white-space:pre-wrap')) diff --git a/test/volt/dev/error_test.exs b/test/volt/dev/error_test.exs index 17294cd..b5b4b03 100644 --- a/test/volt/dev/error_test.exs +++ b/test/volt/dev/error_test.exs @@ -25,6 +25,28 @@ defmodule Volt.Dev.ErrorTest do """ end + test "highlights the frame with Lumis when a parser covers the language", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "app.ts") + File.write!(path, "/* a\n comment */\nconst a = 1 < 2\nconst = ;\n") + {:error, diagnostics} = OXC.parse(File.read!(path), "app.ts") + + assert [%{frame_html: html}] = Error.entries(diagnostics, file: path) + assert html =~ ~s(> 4 | ) + assert html =~ ~s( 3 | ) + assert html =~ ~s( comment */) + assert html =~ "<" + refute html =~ "1 < 2" + assert length(String.split(html, "\n")) == 5 + end + + test "keeps only the plain frame without a parser for the language", %{tmp_dir: tmp_dir} do + path = Path.join(tmp_dir, "app.css") + File.write!(path, ".a { color: red\n") + + assert [%{frame: "> 1 | .a { color: red" <> _, frame_html: nil}] = + Error.entries(%{message: "x", position: {1, 2}}, file: path) + end + test "keeps a diagnostic's own file when it differs from the source path" do diagnostic = %{message: "boom", file: "other.ts", position: {2, 1}, details: "try this"} From a78475f8f6bc3cc78b418bf4814db278e9d73600 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 30 Sep 2026 11:57:59 +0400 Subject: [PATCH 072/128] Style the error overlay after the Volt logo A deep purple panel with the logo's purple-to-cyan gradient bar, the drop and bolt mark in the header, and matching frame colors. --- lib/volt/dev/error.ex | 2 +- priv/ts/client/overlay.ts | 60 ++++++++++++++++++++++++------------ test/volt/dev/error_test.exs | 4 +-- 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/lib/volt/dev/error.ex b/lib/volt/dev/error.ex index 45542ff..d1438e7 100644 --- a/lib/volt/dev/error.ex +++ b/lib/volt/dev/error.ex @@ -122,7 +122,7 @@ defmodule Volt.Dev.Error do # The error line's marker and caret stand out; other gutters are dim. defp html_gutter(gutter) do - color = if String.contains?(gutter, [">", "^"]), do: "#ff5555", else: "#6e7681" + color = if String.contains?(gutter, [">", "^"]), do: "#ff6b9a", else: "#6b5b8f" ~s(#{String.replace(gutter, ">", ">")}) end diff --git a/priv/ts/client/overlay.ts b/priv/ts/client/overlay.ts index 44f6542..97e7857 100644 --- a/priv/ts/client/overlay.ts +++ b/priv/ts/client/overlay.ts @@ -1,8 +1,19 @@ const VOLT_ERROR_OVERLAY_ID = 'volt-error-overlay' -const VOLT_ERROR_OVERLAY_STYLE = - 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.66);padding:6vh 16px;overflow:auto' -const VOLT_ERROR_PANEL_STYLE = - 'box-sizing:border-box;max-width:960px;margin:0 auto;padding:24px 28px;background:#181818;border-top:6px solid #ff5555;border-radius:6px;box-shadow:0 12px 32px rgba(0,0,0,0.5);color:#e8e8e8;font:14px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace' + +// Colors from the Volt logo: a purple-to-cyan drop and bolt on deep purple. +const PURPLE = '#9b3dea' +const CYAN = '#1de4ef' +const ERROR = '#ff6b9a' +const DIM = '#8a7aa8' + +const OVERLAY_STYLE = + 'position:fixed;inset:0;z-index:99999;background:rgba(20,8,40,0.72);padding:6vh 16px;overflow:auto' +const PANEL_STYLE = `box-sizing:border-box;max-width:960px;margin:0 auto;overflow:hidden;background:#1a0f2e;border:1px solid #3a2466;border-radius:10px;box-shadow:0 16px 48px rgba(10,4,24,0.6);color:#ece6f7;font:14px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace` +const BAR_STYLE = `height:4px;background:linear-gradient(90deg,${PURPLE},${CYAN})` +const FRAME_STYLE = + 'margin:0.75em 0 0;padding:12px 16px;background:#120a22;border:1px solid #2a1a48;border-radius:6px;overflow-x:auto;color:#e6edf3;font:inherit' + +const LOGO = `` export type VoltError = { message: string @@ -28,44 +39,43 @@ export function renderErrorOverlay(errors: VoltError[], options: VoltErrorOverla clearErrorOverlay() - const overlay = document.createElement('div') + const overlay = block('div', null, OVERLAY_STYLE) overlay.id = VOLT_ERROR_OVERLAY_ID - overlay.style.cssText = VOLT_ERROR_OVERLAY_STYLE overlay.onclick = (event) => { if (event.target === overlay) overlay.remove() } - const panel = block('div', null, VOLT_ERROR_PANEL_STYLE) - panel.append(block('div', `[Volt] ${title}`, 'color:#ff5555;font-weight:bold')) + const panel = block('div', null, PANEL_STYLE) + const body = block('div', null, 'padding:20px 28px 24px') + panel.append(block('div', null, BAR_STYLE), body) + body.append(header(title, errors.length)) for (const error of errors) { const entry = block('section', null, 'margin-top:1.25em') const location = locationText(error) - if (location) entry.append(block('div', location, 'color:#9aa0a6')) - entry.append(block('div', error.message, 'color:#ff8080;white-space:pre-wrap')) + if (location) entry.append(block('div', location, `color:${CYAN}`)) + entry.append(block('div', error.message, `color:${ERROR};white-space:pre-wrap`)) if (error.frame) { - const frame = block( - 'pre', - error.frame, - 'margin:0.75em 0 0;padding:12px 16px;background:#0d1117;border-radius:4px;overflow-x:auto;color:#e6edf3;font:inherit' - ) + const frame = block('pre', error.frame, FRAME_STYLE) // Built by the dev server from escaped, syntax-highlighted source. if (error.frame_html) frame.innerHTML = error.frame_html entry.append(frame) } if (error.hint) { - entry.append(block('div', error.hint, 'margin-top:0.5em;color:#8ab4f8;white-space:pre-wrap')) + entry.append( + block('div', error.hint, `margin-top:0.5em;color:${PURPLE};white-space:pre-wrap`) + ) } - panel.append(entry) + body.append(entry) } - panel.append( + body.append( block( 'div', 'Fix the error to dismiss this overlay, or click outside it.', - 'margin-top:1.5em;color:#777;font-size:12px' + `margin-top:1.5em;color:${DIM};font-size:12px` ) ) overlay.append(panel) @@ -78,6 +88,18 @@ export function clearErrorOverlay() { } } +function header(title: string, count: number) { + const row = block('div', null, 'display:flex;align-items:center;gap:10px') + const logo = block('span', null, 'display:flex') + logo.innerHTML = LOGO + row.append( + logo, + block('span', 'Volt', 'font-weight:bold;letter-spacing:0.02em'), + block('span', count > 1 ? `${title} · ${count}` : title, `color:${ERROR};font-weight:bold`) + ) + return row +} + function block(tag: string, text: string | null, style: string) { const element = document.createElement(tag) element.style.cssText = style diff --git a/test/volt/dev/error_test.exs b/test/volt/dev/error_test.exs index b5b4b03..a849f33 100644 --- a/test/volt/dev/error_test.exs +++ b/test/volt/dev/error_test.exs @@ -31,8 +31,8 @@ defmodule Volt.Dev.ErrorTest do {:error, diagnostics} = OXC.parse(File.read!(path), "app.ts") assert [%{frame_html: html}] = Error.entries(diagnostics, file: path) - assert html =~ ~s(> 4 | ) - assert html =~ ~s( 3 | ) + assert html =~ ~s(> 4 | ) + assert html =~ ~s( 3 | ) assert html =~ ~s( comment */) assert html =~ "<" refute html =~ "1 < 2" From b2da268765e09c82eae94789e1c420cf926d4b63 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 30 Sep 2026 12:13:56 +0400 Subject: [PATCH 073/128] Vendor npm packages for priv browser code and render the overlay with lit-html mix volt.priv.vendor installs the packages a priv package.json lists and keeps only the files the sources reach, so they ship in the Hex package. Volt.Priv.bundle!/3 bundles a priv entry with them. The error overlay is a element rendered with lit-html into a Shadow DOM, so app styles cannot affect it, and it no longer shows the logo. --- .gitattributes | 2 + .reach.exs | 1 + CHANGELOG.md | 2 + guides/features/plugins.md | 29 + lib/mix/tasks/volt/priv/vendor.ex | 52 ++ lib/volt/builder/rewriter.ex | 10 +- lib/volt/dev_server.ex | 13 +- lib/volt/priv.ex | 27 + lib/volt/priv/vendor.ex | 242 +++++++ lib/volt/test/browser_runner.ex | 2 +- mix.exs | 1 + priv/ts/client/overlay.ts | 201 +++--- .../node_modules/@types/trusted-types/LICENSE | 21 + .../@types/trusted-types/lib/index.d.ts | 64 ++ .../@types/trusted-types/package.json | 45 ++ priv/ts/node_modules/lit-html/LICENSE | 28 + .../lit-html/development/directive.d.ts | 66 ++ .../development/directives/unsafe-html.d.ts | 27 + .../lit-html/development/lit-html.d.ts | 621 +++++++++++++++++ priv/ts/node_modules/lit-html/directive.js | 7 + .../lit-html/directives/unsafe-html.js | 7 + priv/ts/node_modules/lit-html/lit-html.js | 7 + priv/ts/node_modules/lit-html/package.json | 629 ++++++++++++++++++ priv/ts/npm.lock | 31 + priv/ts/package.json | 6 + test/volt/priv/vendor_test.exs | 67 ++ 26 files changed, 2114 insertions(+), 94 deletions(-) create mode 100644 .gitattributes create mode 100644 lib/mix/tasks/volt/priv/vendor.ex create mode 100644 lib/volt/priv/vendor.ex create mode 100644 priv/ts/node_modules/@types/trusted-types/LICENSE create mode 100644 priv/ts/node_modules/@types/trusted-types/lib/index.d.ts create mode 100644 priv/ts/node_modules/@types/trusted-types/package.json create mode 100644 priv/ts/node_modules/lit-html/LICENSE create mode 100644 priv/ts/node_modules/lit-html/development/directive.d.ts create mode 100644 priv/ts/node_modules/lit-html/development/directives/unsafe-html.d.ts create mode 100644 priv/ts/node_modules/lit-html/development/lit-html.d.ts create mode 100644 priv/ts/node_modules/lit-html/directive.js create mode 100644 priv/ts/node_modules/lit-html/directives/unsafe-html.js create mode 100644 priv/ts/node_modules/lit-html/lit-html.js create mode 100644 priv/ts/node_modules/lit-html/package.json create mode 100644 priv/ts/npm.lock create mode 100644 priv/ts/package.json create mode 100644 test/volt/priv/vendor_test.exs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8db939f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +priv/ts/node_modules/** linguist-vendored +priv/ts/npm.lock linguist-generated diff --git a/.reach.exs b/.reach.exs index a7541bb..3556620 100644 --- a/.reach.exs +++ b/.reach.exs @@ -140,6 +140,7 @@ infrastructure = [ "Volt.Dev", "Volt.Dev.Cleanup", "Volt.Dev.Error", + "Volt.Priv.Vendor", "Volt.DevServer.ClientTag", "Volt.Dev.Assets", "Volt.Dev.Session.Call", diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b17b0f..6d40e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,11 @@ ### Added - The error overlay shows each error's file, line, column, source frame, and hint. +- The error overlay is a `` element rendered with lit-html into a Shadow DOM, so the app's styles cannot affect it. - Highlight the error overlay's source frame with the optional `lumis` dependency and its parser packages, such as `lumis_wasm_typescript`. - `Volt.HMR.clear_error/2` hides errors reported with `Volt.HMR.error/3`. Errors stay current until cleared, so browsers that connect later show them. - The dev server adds the HMR client to HTML pages the app renders. +- `mix volt.priv.vendor` vendors npm packages for browser code under `priv`, keeping only the files the sources reach, and `Volt.Priv.bundle!/3` bundles a `priv` entry with them. See "Browser code with npm packages" in the plugins guide. ### Changed diff --git a/guides/features/plugins.md b/guides/features/plugins.md index 199a93a..768d90d 100644 --- a/guides/features/plugins.md +++ b/guides/features/plugins.md @@ -305,6 +305,35 @@ iodata containing multiple nodes, or a list of those values. `render!/4` preserv TypeScript for the subsequent Volt build; use `js!/4` when the caller needs compiled browser JavaScript immediately. +### Browser code with npm packages + +Browser code in `priv` can import npm packages that ship with your library. List them +in a `package.json` beside the sources, pinned to exact versions: + +```json +// priv/ts/package.json +{"private": true, "dependencies": {"lit-html": "3.3.3"}} +``` + +Then vendor them and commit the result: + +```bash +mix volt.priv.vendor priv/ts +``` + +`priv/ts/node_modules` keeps only the files your sources reach: the browser build of +each imported module, its type declarations, and each package's `package.json` and +license, so the files ship in your Hex package without an install at runtime. +`priv/ts/npm.lock` pins the versions; pass `--update` to resolve them again. Add +`mix volt.priv.vendor priv/ts --check` to CI to catch a stale vendored tree. + +`Volt.Priv.bundle!/3` bundles an entry with everything it imports, including the +vendored packages: + +```elixir +Volt.Priv.bundle!({:my_framework, "ts"}, "widget.ts", format: :esm) +``` + ### Example: AST transform with OXC Use `transform/2` to modify compiled JavaScript. OXC provides `parse/2`, `postwalk/3`, and `patch_string/2` for AST-based transforms: diff --git a/lib/mix/tasks/volt/priv/vendor.ex b/lib/mix/tasks/volt/priv/vendor.ex new file mode 100644 index 0000000..5cf44d9 --- /dev/null +++ b/lib/mix/tasks/volt/priv/vendor.ex @@ -0,0 +1,52 @@ +defmodule Mix.Tasks.Volt.Priv.Vendor do + use Mix.Task + + @shortdoc "Vendor npm packages for browser code under priv" + + @moduledoc """ + Vendors the npm packages that `DIR/package.json` lists into `DIR/node_modules`, + keeping only the files the sources in `DIR` reach. See `Volt.Priv.Vendor`. + + mix volt.priv.vendor priv/ts + mix volt.priv.vendor priv/ts --update + mix volt.priv.vendor priv/ts --check + + ## Options + + * `--update` — resolve the versions again instead of using `DIR/npm.lock` + * `--check` — fail if the vendored files differ from a fresh vendoring + """ + + @impl true + def run(args) do + {opts, dirs} = OptionParser.parse!(args, strict: [update: :boolean, check: :boolean]) + + if dirs == [], do: Mix.raise("Expected a directory, such as: mix volt.priv.vendor priv/ts") + + Mix.Task.run("app.config") + Application.ensure_all_started(:req) + + Enum.each(dirs, fn dir -> + if opts[:check], do: check!(dir), else: vendor!(dir, opts) + end) + end + + defp vendor!(dir, opts) do + files = Volt.Priv.Vendor.run!(dir, update: opts[:update] == true) + Mix.shell().info("Vendored #{length(files)} files into #{Path.join(dir, "node_modules")}") + end + + defp check!(dir) do + case Volt.Priv.Vendor.stale(dir) do + [] -> + Mix.shell().info(IO.ANSI.format([:green, "✓ #{dir} vendored packages are current"])) + + stale -> + Mix.raise(""" + #{dir}/node_modules is out of date. Run `mix volt.priv.vendor #{dir}`. Differing files: + + #{Enum.map(stale, &[" ", &1, "\n"])} + """) + end + end +end diff --git a/lib/volt/builder/rewriter.ex b/lib/volt/builder/rewriter.ex index d48f6fa..b856944 100644 --- a/lib/volt/builder/rewriter.ex +++ b/lib/volt/builder/rewriter.ex @@ -146,15 +146,7 @@ defmodule Volt.Builder.Rewriter do patches end - defp preload_helper do - {:volt, "ts"} - |> Volt.Priv.path("client/preload-global.ts") - |> Volt.JS.Runtime.Bundler.bundle_file() - |> case do - {:ok, code} -> code <> "\n" - {:error, reason} -> raise "could not bundle Volt preload helper: #{inspect(reason)}" - end - end + defp preload_helper, do: Volt.Priv.bundle!({:volt, "ts"}, "client/preload-global.ts") <> "\n" defp worker_filename_map(worker_specs, ctx) do worker_specs diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index 4009b8c..d327d91 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -952,15 +952,10 @@ defmodule Volt.DevServer do end defp client_module!(heartbeat_interval) do - entry = Volt.Priv.path(@support_modules, "client/hmr.ts") - - case Volt.JS.Runtime.Bundler.bundle_file(entry, - format: :esm, - define: %{"__VOLT_HEARTBEAT__" => Integer.to_string(heartbeat_interval)} - ) do - {:ok, code} when is_binary(code) -> code - {:error, reason} -> raise "Could not bundle Volt dev client: #{inspect(reason)}" - end + Volt.Priv.bundle!(@support_modules, "client/hmr.ts", + format: :esm, + define: %{"__VOLT_HEARTBEAT__" => Integer.to_string(heartbeat_interval)} + ) end defp support_module!(relative, bindings) do diff --git a/lib/volt/priv.ex b/lib/volt/priv.ex index 3e8e699..c57d457 100644 --- a/lib/volt/priv.ex +++ b/lib/volt/priv.ex @@ -23,6 +23,13 @@ defmodule Volt.Priv do Volt build. `js!/3` and `js!/4` additionally compile the rendered source to browser JavaScript. This keeps templates valid TypeScript or JavaScript files instead of requiring EEx or textual source replacement. + + `bundle!/3` bundles an entry with everything it imports into one module. npm + packages it imports are vendored beside the sources with `mix volt.priv.vendor` + (see `Volt.Priv.Vendor`), so they ship in the package and no install happens at + runtime: + + Volt.Priv.bundle!(assets, "widget.ts", format: :esm) """ @type source :: atom() | {atom(), String.t()} @@ -66,6 +73,26 @@ defmodule Volt.Priv do |> rewrite_specifiers(opts[:rewrite_specifiers], relative) end + @doc """ + Bundles an entry under `priv` with the modules it imports, including npm packages + vendored beside it with `mix volt.priv.vendor`. + + Options are passed to `OXC.bundle/2`, such as `:format` and `:define`. + """ + @spec bundle(source(), String.t(), keyword()) :: {:ok, String.t()} | {:error, term()} + def bundle(source, relative, opts \\ []) when is_binary(relative) do + source |> path(relative) |> Volt.JS.Runtime.Bundler.bundle_file(opts) + end + + @doc "Like `bundle/3`, but raises when the entry cannot be bundled." + @spec bundle!(source(), String.t(), keyword()) :: String.t() + def bundle!(source, relative, opts \\ []) do + case bundle(source, relative, opts) do + {:ok, code} -> code + {:error, reason} -> raise "could not bundle #{relative}: #{inspect(reason)}" + end + end + @doc """ Emits browser JavaScript for a support file stored under `priv`. diff --git a/lib/volt/priv/vendor.ex b/lib/volt/priv/vendor.ex new file mode 100644 index 0000000..61a3103 --- /dev/null +++ b/lib/volt/priv/vendor.ex @@ -0,0 +1,242 @@ +defmodule Volt.Priv.Vendor do + @moduledoc """ + Vendors npm packages for browser code kept under `priv`. + + List the packages in a `package.json` beside the sources, pinned to exact + versions: + + ```json + {"private": true, "dependencies": {"lit-html": "3.3.3"}} + ``` + + Then run `mix volt.priv.vendor priv/ts`. The packages are installed with the + versions locked in `npm.lock`, and `node_modules` beside the sources keeps only + the files they reach: the browser build of every imported module, the type + declarations behind it, and each package's `package.json` and license. Nothing + is installed at runtime, and the files ship in the Hex package. + + `Volt.Priv.bundle!/3` resolves imports such as `import { html } from 'lit-html'` + from there, and TypeScript finds the types. + """ + + alias NPM.Resolution.PackageResolver + + @source_extensions ~w(.ts .tsx .mts .js .jsx .mjs) + @type_conditions ["types", "import", "default"] + + @doc """ + Vendors the packages that `dir/package.json` lists into `dir/node_modules`. + + ## Options + + * `:update` — resolve the versions again instead of using `dir/npm.lock` + """ + @spec run!(Path.t(), keyword()) :: [Path.t()] + def run!(dir, opts \\ []) do + with_install(dir, opts, fn install_dir, files -> + node_modules = Path.join(dir, "node_modules") + File.rm_rf!(node_modules) + + for file <- files do + target = Path.join(node_modules, file) + File.mkdir_p!(Path.dirname(target)) + File.cp!(Path.join([install_dir, "node_modules", file]), target) + end + + File.cp!(Path.join(install_dir, "npm.lock"), Path.join(dir, "npm.lock")) + files + end) + end + + @doc "Returns the vendored files under `dir/node_modules` that differ from a fresh vendoring." + @spec stale(Path.t()) :: [Path.t()] + def stale(dir) do + node_modules = Path.join(dir, "node_modules") + + with_install(dir, [], fn install_dir, files -> + present = node_modules |> Path.join("**") |> Path.wildcard(match_dot: true) + present = for path <- present, File.regular?(path), do: Path.relative_to(path, node_modules) + + changed = + Enum.reject(files, fn file -> + File.read(Path.join(node_modules, file)) == + File.read(Path.join([install_dir, "node_modules", file])) + end) + + Enum.sort(changed ++ (present -- files)) + end) + end + + defp with_install(dir, opts, fun) do + packages = dir |> Path.join("package.json") |> File.read!() |> Jason.decode!() + packages = Map.get(packages, "dependencies", %{}) + lockfile = Path.join(dir, "npm.lock") + + install_dir = + Path.join(System.tmp_dir!(), "volt-vendor-#{System.unique_integer([:positive])}") + + try do + Volt.JS.Runtime.Installer.install!(packages, + install_dir: install_dir, + lockfile: + if(File.regular?(lockfile) and not Keyword.get(opts, :update, false), do: lockfile) + ) + + fun.(install_dir, reachable(dir, packages, Path.join(install_dir, "node_modules"))) + after + File.rm_rf!(install_dir) + end + end + + @doc false + # The files the sources in `dir` reach in `node_modules`, relative to it. + @spec reachable(Path.t(), map(), Path.t()) :: [Path.t()] + def reachable(dir, packages, node_modules) do + roots = + for source <- sources(dir), + specifier <- imports(source), + Map.has_key?(packages, package_name(specifier)), + kind <- [:js, :types], + do: {kind, specifier, node_modules} + + roots + |> walk(MapSet.new()) + |> Enum.flat_map(&[&1 | package_files(&1, node_modules)]) + |> Enum.uniq() + |> Enum.map(&Path.relative_to(&1, node_modules)) + |> Enum.sort() + end + + defp walk([], seen), do: seen + + defp walk([{kind, specifier, from} | rest], seen) do + case resolve(kind, specifier, from) do + {:ok, path} -> + if MapSet.member?(seen, path) do + walk(rest, seen) + else + next = for import <- imports(path), do: {kind, import, Path.dirname(path)} + walk(next ++ rest, MapSet.put(seen, path)) + end + + :error -> + walk(rest, seen) + end + end + + defp resolve(:js, specifier, from) do + specifier + |> PackageResolver.resolve(from, + conditions: Volt.JS.Resolution.browser_conditions(), + extensions: Volt.JS.Extensions.node_resolvable() + ) + |> file_result() + end + + # TypeScript's own lookup: a `types` export or a declaration beside the module, + # the package's `types` field, then the same path under `@types`. + defp resolve(:types, specifier, from) do + with :error <- declaration(specifier, from), + :error <- package_types(specifier, from), + false <- relative?(specifier) or String.starts_with?(specifier, "@types/") do + resolve(:types, types_specifier(specifier), from) + else + true -> :error + found -> found + end + end + + # Declarations drop the module extension: `lib/index.js` is typed by `lib/index.d.ts`. + defp declaration(specifier, from) do + Enum.find_value([specifier, Path.rootname(specifier, Path.extname(specifier))], :error, fn + specifier -> + with {:ok, path} <- + specifier + |> PackageResolver.resolve(from, + conditions: @type_conditions, + extensions: [".d.ts"] + ) + |> file_result(), + {:ok, declaration} <- declaration_for(path) do + {:ok, declaration} + else + _ -> nil + end + end) + end + + defp file_result({:ok, path}), do: {:ok, path} + defp file_result(_other), do: :error + + defp declaration_for(path) do + declaration = + if String.ends_with?(path, ".d.ts"), + do: path, + else: Path.rootname(path) <> ".d.ts" + + if File.regular?(declaration), do: {:ok, declaration}, else: :error + end + + # A package's `types` field describes its root only. + defp package_types(specifier, from) do + with true <- not relative?(specifier) and package_name(specifier) == specifier, + {:ok, package_dir} <- package_dir(specifier, from), + {:ok, json} <- File.read(Path.join(package_dir, "package.json")), + %{} = package <- Jason.decode!(json), + types when is_binary(types) <- package["types"] || package["typings"] do + declaration_for(Path.join(package_dir, types)) + else + _ -> :error + end + end + + defp package_dir(name, from) do + case PackageResolver.find_node_modules(from) do + nil -> :error + node_modules -> {:ok, Path.join(node_modules, name)} + end + end + + # Each reached package also keeps its `package.json` and license files. + defp package_files(path, node_modules) do + package_dir = + Path.join(node_modules, path |> Path.relative_to(node_modules) |> package_name()) + + Path.wildcard(Path.join(package_dir, "{package.json,LICENSE*,license*}")) + end + + defp sources(dir) do + dir + |> Path.join("**/*") + |> Path.wildcard() + |> Enum.reject(&String.contains?(Path.relative_to(&1, dir), "node_modules")) + |> Enum.filter(&(Path.extname(&1) in @source_extensions)) + end + + # Import sources, including type-only imports, re-exports, and `import()` types. + defp imports(path) do + with {:ok, source} <- File.read(path), + {:ok, ast} <- OXC.parse(source, Path.basename(path)) do + OXC.collect(ast, fn + %{source: %{value: specifier}} when is_binary(specifier) -> {:keep, specifier} + _node -> :skip + end) + else + _ -> [] + end + end + + defp relative?(specifier), do: String.starts_with?(specifier, [".", "/"]) + + defp package_name("@" <> _ = specifier), + do: specifier |> String.split("/") |> Enum.take(2) |> Enum.join("/") + + defp package_name(specifier), do: specifier |> String.split("/", parts: 2) |> hd() + + # `@scope/pkg/sub` is typed by `@types/scope__pkg/sub`, `pkg/sub` by `@types/pkg/sub`. + defp types_specifier(specifier) do + name = package_name(specifier) + subpath = String.replace_prefix(specifier, name, "") + "@types/" <> String.replace(String.trim_leading(name, "@"), "/", "__") <> subpath + end +end diff --git a/lib/volt/test/browser_runner.ex b/lib/volt/test/browser_runner.ex index 1ad445b..a879c85 100644 --- a/lib/volt/test/browser_runner.ex +++ b/lib/volt/test/browser_runner.ex @@ -56,7 +56,7 @@ defmodule Volt.Test.BrowserRunner do end defp browser_runtime_code do - Volt.JS.Runtime.Bundler.bundle_file(Volt.Priv.path({:volt, "ts"}, "test/browser.ts")) + Volt.Priv.bundle({:volt, "ts"}, "test/browser.ts") end defp ensure_playwright_started(%Config{} = config, timeout) do diff --git a/mix.exs b/mix.exs index 36d07e4..0086d82 100644 --- a/mix.exs +++ b/mix.exs @@ -70,6 +70,7 @@ defmodule Volt.MixProject do lint: [ "format --check-formatted", "volt.js.check --type-aware --type-check", + "volt.priv.vendor priv/ts --check", "credo --strict", "ex_dna --min-mass 20", "reach.check --arch --dead-code --smells --strict", diff --git a/priv/ts/client/overlay.ts b/priv/ts/client/overlay.ts index 97e7857..3d3edf6 100644 --- a/priv/ts/client/overlay.ts +++ b/priv/ts/client/overlay.ts @@ -1,19 +1,5 @@ -const VOLT_ERROR_OVERLAY_ID = 'volt-error-overlay' - -// Colors from the Volt logo: a purple-to-cyan drop and bolt on deep purple. -const PURPLE = '#9b3dea' -const CYAN = '#1de4ef' -const ERROR = '#ff6b9a' -const DIM = '#8a7aa8' - -const OVERLAY_STYLE = - 'position:fixed;inset:0;z-index:99999;background:rgba(20,8,40,0.72);padding:6vh 16px;overflow:auto' -const PANEL_STYLE = `box-sizing:border-box;max-width:960px;margin:0 auto;overflow:hidden;background:#1a0f2e;border:1px solid #3a2466;border-radius:10px;box-shadow:0 16px 48px rgba(10,4,24,0.6);color:#ece6f7;font:14px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace` -const BAR_STYLE = `height:4px;background:linear-gradient(90deg,${PURPLE},${CYAN})` -const FRAME_STYLE = - 'margin:0.75em 0 0;padding:12px 16px;background:#120a22;border:1px solid #2a1a48;border-radius:6px;overflow-x:auto;color:#e6edf3;font:inherit' - -const LOGO = `` +import { html, render } from 'lit-html' +import { unsafeHTML } from 'lit-html/directives/unsafe-html.js' export type VoltError = { message: string @@ -29,84 +15,149 @@ type VoltErrorOverlayOptions = { title?: string } -export function renderErrorOverlay(errors: VoltError[], options: VoltErrorOverlayOptions = {}) { - const title = options.title ?? 'Build error' - console.error(`[Volt] ${title}:\n${errors.map(errorText).join('\n\n')}`) +const TAG = 'volt-error-overlay' - if (typeof document === 'undefined') { - return +// Colors from the Volt logo: purple to cyan on deep purple. +const styles = html`` - clearErrorOverlay() +class VoltErrorOverlay extends HTMLElement { + private root = this.attachShadow({ mode: 'open' }) - const overlay = block('div', null, OVERLAY_STYLE) - overlay.id = VOLT_ERROR_OVERLAY_ID - overlay.onclick = (event) => { - if (event.target === overlay) overlay.remove() + constructor() { + super() + this.addEventListener('click', (event) => { + const target = event.composedPath()[0] + if (target instanceof Element && target.classList.contains('backdrop')) this.remove() + }) } - const panel = block('div', null, PANEL_STYLE) - const body = block('div', null, 'padding:20px 28px 24px') - panel.append(block('div', null, BAR_STYLE), body) - body.append(header(title, errors.length)) - - for (const error of errors) { - const entry = block('section', null, 'margin-top:1.25em') - const location = locationText(error) + show(errors: VoltError[], title: string) { + render( + html`${styles} +
+
+
+

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

+ ${errors.map(errorTemplate)} +
Fix the error to dismiss this overlay, or click outside it.
+
+
+
`, + this.root + ) + } +} - if (location) entry.append(block('div', location, `color:${CYAN}`)) - entry.append(block('div', error.message, `color:${ERROR};white-space:pre-wrap`)) - if (error.frame) { - const frame = block('pre', error.frame, FRAME_STYLE) - // Built by the dev server from escaped, syntax-highlighted source. - if (error.frame_html) frame.innerHTML = error.frame_html - entry.append(frame) - } - if (error.hint) { - entry.append( - block('div', error.hint, `margin-top:0.5em;color:${PURPLE};white-space:pre-wrap`) - ) +function errorTemplate(error: VoltError) { + const location = locationText(error) + + return html`
+ ${location ? html`
${location}
` : null} +
${error.message}
+ ${ + error.frame + ? // The dev server builds `frame_html` from escaped, syntax-highlighted source. + html`
${error.frame_html ? unsafeHTML(error.frame_html) : error.frame}
` + : null } + ${error.hint ? html`
${error.hint}
` : null} +
` +} + +export function renderErrorOverlay(errors: VoltError[], options: VoltErrorOverlayOptions = {}) { + const title = options.title ?? 'Build error' + console.error(`[Volt] ${title}:\n${errors.map(errorText).join('\n\n')}`) - body.append(entry) + if (typeof document === 'undefined') { + return } - body.append( - block( - 'div', - 'Fix the error to dismiss this overlay, or click outside it.', - `margin-top:1.5em;color:${DIM};font-size:12px` - ) - ) - overlay.append(panel) + if (!customElements.get(TAG)) customElements.define(TAG, VoltErrorOverlay) + + clearErrorOverlay() + const overlay = document.createElement(TAG) as VoltErrorOverlay + overlay.show(errors, title) document.body.appendChild(overlay) } export function clearErrorOverlay() { if (typeof document !== 'undefined') { - document.getElementById(VOLT_ERROR_OVERLAY_ID)?.remove() + document.querySelector(TAG)?.remove() } } -function header(title: string, count: number) { - const row = block('div', null, 'display:flex;align-items:center;gap:10px') - const logo = block('span', null, 'display:flex') - logo.innerHTML = LOGO - row.append( - logo, - block('span', 'Volt', 'font-weight:bold;letter-spacing:0.02em'), - block('span', count > 1 ? `${title} · ${count}` : title, `color:${ERROR};font-weight:bold`) - ) - return row -} - -function block(tag: string, text: string | null, style: string) { - const element = document.createElement(tag) - element.style.cssText = style - if (text !== null) element.textContent = text - return element -} - function locationText({ file, line, column }: VoltError) { if (!file) return null if (!line) return file diff --git a/priv/ts/node_modules/@types/trusted-types/LICENSE b/priv/ts/node_modules/@types/trusted-types/LICENSE new file mode 100644 index 0000000..9e841e7 --- /dev/null +++ b/priv/ts/node_modules/@types/trusted-types/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/priv/ts/node_modules/@types/trusted-types/lib/index.d.ts b/priv/ts/node_modules/@types/trusted-types/lib/index.d.ts new file mode 100644 index 0000000..2c7ccd4 --- /dev/null +++ b/priv/ts/node_modules/@types/trusted-types/lib/index.d.ts @@ -0,0 +1,64 @@ +// The main type definitions. Packages that do not want to pollute the global +// scope with Trusted Types (e.g. libraries whose users may not be using Trusted +// Types) can import the types directly from 'trusted-types/lib'. + +export type FnNames = keyof TrustedTypePolicyOptions; +export type Args = Parameters>; + +export class TrustedHTML { + private constructor(); // To prevent instantiting with 'new'. + private brand: true; // To prevent structural typing. +} + +export class TrustedScript { + private constructor(); // To prevent instantiting with 'new'. + private brand: true; // To prevent structural typing. +} + +export class TrustedScriptURL { + private constructor(); // To prevent instantiting with 'new'. + private brand: true; // To prevent structural typing. +} + +export abstract class TrustedTypePolicyFactory { + createPolicy( + policyName: string, + policyOptions?: Options, + ): Pick, "name" | Extract>; + isHTML(value: unknown): value is TrustedHTML; + isScript(value: unknown): value is TrustedScript; + isScriptURL(value: unknown): value is TrustedScriptURL; + readonly emptyHTML: TrustedHTML; + readonly emptyScript: TrustedScript; + getAttributeType(tagName: string, attribute: string, elementNs?: string, attrNs?: string): string | null; + getPropertyType(tagName: string, property: string, elementNs?: string): string | null; + readonly defaultPolicy: TrustedTypePolicy | null; +} + +export abstract class TrustedTypePolicy { + readonly name: string; + createHTML(...args: Args): TrustedHTML; + createScript(...args: Args): TrustedScript; + createScriptURL(...args: Args): TrustedScriptURL; +} + +export interface TrustedTypePolicyOptions { + createHTML?: ((input: string, ...arguments: any[]) => string) | undefined; + createScript?: ((input: string, ...arguments: any[]) => string) | undefined; + createScriptURL?: ((input: string, ...arguments: any[]) => string) | undefined; +} + +// The Window object is augmented with the following properties in browsers that +// support Trusted Types. Users of the 'trusted-types/lib' entrypoint can cast +// window as TrustedTypesWindow to access these properties. +export interface TrustedTypesWindow { + // `trustedTypes` is left intentionally optional to make sure that + // people handle the case when their code is running in a browser not + // supporting trustedTypes. + trustedTypes?: TrustedTypePolicyFactory | undefined; + TrustedHTML: typeof TrustedHTML; + TrustedScript: typeof TrustedScript; + TrustedScriptURL: typeof TrustedScriptURL; + TrustedTypePolicyFactory: typeof TrustedTypePolicyFactory; + TrustedTypePolicy: typeof TrustedTypePolicy; +} diff --git a/priv/ts/node_modules/@types/trusted-types/package.json b/priv/ts/node_modules/@types/trusted-types/package.json new file mode 100644 index 0000000..eda8c73 --- /dev/null +++ b/priv/ts/node_modules/@types/trusted-types/package.json @@ -0,0 +1,45 @@ +{ + "name": "@types/trusted-types", + "version": "2.0.7", + "description": "TypeScript definitions for trusted-types", + "homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/trusted-types", + "license": "MIT", + "contributors": [ + { + "name": "Jakub Vrana", + "githubUsername": "vrana", + "url": "https://github.com/vrana" + }, + { + "name": "Damien Engels", + "githubUsername": "engelsdamien", + "url": "https://github.com/engelsdamien" + }, + { + "name": "Emanuel Tesar", + "githubUsername": "siegrift", + "url": "https://github.com/siegrift" + }, + { + "name": "Bjarki", + "githubUsername": "bjarkler", + "url": "https://github.com/bjarkler" + }, + { + "name": "Sebastian Silbermann", + "githubUsername": "eps1lon", + "url": "https://github.com/eps1lon" + } + ], + "main": "", + "types": "index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/DefinitelyTyped/DefinitelyTyped.git", + "directory": "types/trusted-types" + }, + "scripts": {}, + "dependencies": {}, + "typesPublisherContentHash": "20982c5e0452e662515e29b41f7be5a3c69e5918a9228929a563d9f1dfdfbbc5", + "typeScriptVersion": "4.5" +} \ No newline at end of file diff --git a/priv/ts/node_modules/lit-html/LICENSE b/priv/ts/node_modules/lit-html/LICENSE new file mode 100644 index 0000000..c8ed226 --- /dev/null +++ b/priv/ts/node_modules/lit-html/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2017 Google LLC. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/priv/ts/node_modules/lit-html/development/directive.d.ts b/priv/ts/node_modules/lit-html/development/directive.d.ts new file mode 100644 index 0000000..022c957 --- /dev/null +++ b/priv/ts/node_modules/lit-html/development/directive.d.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +import { Disconnectable, Part } from './lit-html.js'; +export { AttributePart, BooleanAttributePart, ChildPart, ElementPart, EventPart, Part, PropertyPart, } from './lit-html.js'; +export interface DirectiveClass { + new (part: PartInfo): Directive; +} +/** + * This utility type extracts the signature of a directive class's render() + * method so we can use it for the type of the generated directive function. + */ +export type DirectiveParameters = Parameters; +/** + * A generated directive function doesn't evaluate the directive, but just + * returns a DirectiveResult object that captures the arguments. + */ +export interface DirectiveResult { +} +export declare const PartType: { + readonly ATTRIBUTE: 1; + readonly CHILD: 2; + readonly PROPERTY: 3; + readonly BOOLEAN_ATTRIBUTE: 4; + readonly EVENT: 5; + readonly ELEMENT: 6; +}; +export type PartType = (typeof PartType)[keyof typeof PartType]; +export interface ChildPartInfo { + readonly type: typeof PartType.CHILD; +} +export interface AttributePartInfo { + readonly type: typeof PartType.ATTRIBUTE | typeof PartType.PROPERTY | typeof PartType.BOOLEAN_ATTRIBUTE | typeof PartType.EVENT; + readonly strings?: ReadonlyArray; + readonly name: string; + readonly tagName: string; +} +export interface ElementPartInfo { + readonly type: typeof PartType.ELEMENT; +} +/** + * Information about the part a directive is bound to. + * + * This is useful for checking that a directive is attached to a valid part, + * such as with directive that can only be used on attribute bindings. + */ +export type PartInfo = ChildPartInfo | AttributePartInfo | ElementPartInfo; +/** + * Creates a user-facing directive function from a Directive class. This + * function has the same parameters as the directive's render() method. + */ +export declare const directive: (c: C) => (...values: DirectiveParameters>) => DirectiveResult; +/** + * Base class for creating custom directives. Users should extend this class, + * implement `render` and/or `update`, and then pass their subclass to + * `directive`. + */ +export declare abstract class Directive implements Disconnectable { + constructor(_partInfo: PartInfo); + get _$isConnected(): boolean; + abstract render(...props: Array): unknown; + update(_part: Part, props: Array): unknown; +} +//# sourceMappingURL=directive.d.ts.map \ No newline at end of file diff --git a/priv/ts/node_modules/lit-html/development/directives/unsafe-html.d.ts b/priv/ts/node_modules/lit-html/development/directives/unsafe-html.d.ts new file mode 100644 index 0000000..8ad4e07 --- /dev/null +++ b/priv/ts/node_modules/lit-html/development/directives/unsafe-html.d.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +import { nothing, TemplateResult, noChange } from '../lit-html.js'; +import { Directive, PartInfo } from '../directive.js'; +export declare class UnsafeHTMLDirective extends Directive { + static directiveName: string; + static resultType: number; + private _value; + private _templateResult?; + constructor(partInfo: PartInfo); + render(value: string | typeof nothing | typeof noChange | undefined | null): typeof noChange | typeof nothing | TemplateResult | null | undefined; +} +/** + * Renders the result as HTML, rather than text. + * + * The values `undefined`, `null`, and `nothing`, will all result in no content + * (empty string) being rendered. + * + * Note, this is unsafe to use with any user-provided input that hasn't been + * sanitized or escaped, as it may lead to cross-site-scripting + * vulnerabilities. + */ +export declare const unsafeHTML: (value: string | typeof noChange | typeof nothing | null | undefined) => import("../directive.js").DirectiveResult; +//# sourceMappingURL=unsafe-html.d.ts.map \ No newline at end of file diff --git a/priv/ts/node_modules/lit-html/development/lit-html.d.ts b/priv/ts/node_modules/lit-html/development/lit-html.d.ts new file mode 100644 index 0000000..a9f454d --- /dev/null +++ b/priv/ts/node_modules/lit-html/development/lit-html.d.ts @@ -0,0 +1,621 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +import type { Directive } from './directive.js'; +import type { TrustedHTML } from 'trusted-types/lib/index.js'; +/** + * Contains types that are part of the unstable debug API. + * + * Everything in this API is not stable and may change or be removed in the future, + * even on patch releases. + */ +export declare namespace LitUnstable { + /** + * When Lit is running in dev mode and `window.emitLitDebugLogEvents` is true, + * we will emit 'lit-debug' events to window, with live details about the update and render + * lifecycle. These can be useful for writing debug tooling and visualizations. + * + * Please be aware that running with window.emitLitDebugLogEvents has performance overhead, + * making certain operations that are normally very cheap (like a no-op render) much slower, + * because we must copy data and dispatch events. + */ + namespace DebugLog { + type Entry = TemplatePrep | TemplateInstantiated | TemplateInstantiatedAndUpdated | TemplateUpdating | BeginRender | EndRender | CommitPartEntry | SetPartValue; + interface TemplatePrep { + kind: 'template prep'; + template: Template; + strings: TemplateStringsArray; + clonableTemplate: HTMLTemplateElement; + parts: TemplatePart[]; + } + interface BeginRender { + kind: 'begin render'; + id: number; + value: unknown; + container: RenderRootNode; + options: RenderOptions | undefined; + part: ChildPart | undefined; + } + interface EndRender { + kind: 'end render'; + id: number; + value: unknown; + container: RenderRootNode; + options: RenderOptions | undefined; + part: ChildPart; + } + interface TemplateInstantiated { + kind: 'template instantiated'; + template: Template | CompiledTemplate; + instance: TemplateInstance; + options: RenderOptions | undefined; + fragment: Node; + parts: Array; + values: unknown[]; + } + interface TemplateInstantiatedAndUpdated { + kind: 'template instantiated and updated'; + template: Template | CompiledTemplate; + instance: TemplateInstance; + options: RenderOptions | undefined; + fragment: Node; + parts: Array; + values: unknown[]; + } + interface TemplateUpdating { + kind: 'template updating'; + template: Template | CompiledTemplate; + instance: TemplateInstance; + options: RenderOptions | undefined; + parts: Array; + values: unknown[]; + } + interface SetPartValue { + kind: 'set part'; + part: Part; + value: unknown; + valueIndex: number; + values: unknown[]; + templateInstance: TemplateInstance; + } + type CommitPartEntry = CommitNothingToChildEntry | CommitText | CommitNode | CommitAttribute | CommitProperty | CommitBooleanAttribute | CommitEventListener | CommitToElementBinding; + interface CommitNothingToChildEntry { + kind: 'commit nothing to child'; + start: ChildNode; + end: ChildNode | null; + parent: Disconnectable | undefined; + options: RenderOptions | undefined; + } + interface CommitText { + kind: 'commit text'; + node: Text; + value: unknown; + options: RenderOptions | undefined; + } + interface CommitNode { + kind: 'commit node'; + start: Node; + parent: Disconnectable | undefined; + value: Node; + options: RenderOptions | undefined; + } + interface CommitAttribute { + kind: 'commit attribute'; + element: Element; + name: string; + value: unknown; + options: RenderOptions | undefined; + } + interface CommitProperty { + kind: 'commit property'; + element: Element; + name: string; + value: unknown; + options: RenderOptions | undefined; + } + interface CommitBooleanAttribute { + kind: 'commit boolean attribute'; + element: Element; + name: string; + value: boolean; + options: RenderOptions | undefined; + } + interface CommitEventListener { + kind: 'commit event listener'; + element: Element; + name: string; + value: unknown; + oldListener: unknown; + options: RenderOptions | undefined; + removeListener: boolean; + addListener: boolean; + } + interface CommitToElementBinding { + kind: 'commit to element binding'; + element: Element; + value: unknown; + options: RenderOptions | undefined; + } + } +} +/** + * Used to sanitize any value before it is written into the DOM. This can be + * used to implement a security policy of allowed and disallowed values in + * order to prevent XSS attacks. + * + * One way of using this callback would be to check attributes and properties + * against a list of high risk fields, and require that values written to such + * fields be instances of a class which is safe by construction. Closure's Safe + * HTML Types is one implementation of this technique ( + * https://github.com/google/safe-html-types/blob/master/doc/safehtml-types.md). + * The TrustedTypes polyfill in API-only mode could also be used as a basis + * for this technique (https://github.com/WICG/trusted-types). + * + * @param node The HTML node (usually either a #text node or an Element) that + * is being written to. Note that this is just an exemplar node, the write + * may take place against another instance of the same class of node. + * @param name The name of an attribute or property (for example, 'href'). + * @param type Indicates whether the write that's about to be performed will + * be to a property or a node. + * @return A function that will sanitize this class of writes. + */ +export type SanitizerFactory = (node: Node, name: string, type: 'property' | 'attribute') => ValueSanitizer; +/** + * A function which can sanitize values that will be written to a specific kind + * of DOM sink. + * + * See SanitizerFactory. + * + * @param value The value to sanitize. Will be the actual value passed into + * the lit-html template literal, so this could be of any type. + * @return The value to write to the DOM. Usually the same as the input value, + * unless sanitization is needed. + */ +export type ValueSanitizer = (value: unknown) => unknown; +/** TemplateResult types */ +declare const HTML_RESULT = 1; +declare const SVG_RESULT = 2; +declare const MATHML_RESULT = 3; +type ResultType = typeof HTML_RESULT | typeof SVG_RESULT | typeof MATHML_RESULT; +declare const ATTRIBUTE_PART = 1; +declare const CHILD_PART = 2; +declare const PROPERTY_PART = 3; +declare const BOOLEAN_ATTRIBUTE_PART = 4; +declare const EVENT_PART = 5; +declare const ELEMENT_PART = 6; +declare const COMMENT_PART = 7; +/** + * The return type of the template tag functions, {@linkcode html} and + * {@linkcode svg} when it hasn't been compiled by @lit-labs/compiler. + * + * A `TemplateResult` object holds all the information about a template + * expression required to render it: the template strings, expression values, + * and type of template (html or svg). + * + * `TemplateResult` objects do not create any DOM on their own. To create or + * update DOM you need to render the `TemplateResult`. See + * [Rendering](https://lit.dev/docs/components/rendering) for more information. + * + */ +export type UncompiledTemplateResult = { + ['_$litType$']: T; + strings: TemplateStringsArray; + values: unknown[]; +}; +/** + * This is a template result that may be either uncompiled or compiled. + * + * In the future, TemplateResult will be this type. If you want to explicitly + * note that a template result is potentially compiled, you can reference this + * type and it will continue to behave the same through the next major version + * of Lit. This can be useful for code that wants to prepare for the next + * major version of Lit. + */ +export type MaybeCompiledTemplateResult = UncompiledTemplateResult | CompiledTemplateResult; +/** + * The return type of the template tag functions, {@linkcode html} and + * {@linkcode svg}. + * + * A `TemplateResult` object holds all the information about a template + * expression required to render it: the template strings, expression values, + * and type of template (html or svg). + * + * `TemplateResult` objects do not create any DOM on their own. To create or + * update DOM you need to render the `TemplateResult`. See + * [Rendering](https://lit.dev/docs/components/rendering) for more information. + * + * In Lit 4, this type will be an alias of + * MaybeCompiledTemplateResult, so that code will get type errors if it assumes + * that Lit templates are not compiled. When deliberately working with only + * one, use either {@linkcode CompiledTemplateResult} or + * {@linkcode UncompiledTemplateResult} explicitly. + */ +export type TemplateResult = UncompiledTemplateResult; +export type HTMLTemplateResult = TemplateResult; +export type SVGTemplateResult = TemplateResult; +export type MathMLTemplateResult = TemplateResult; +/** + * A TemplateResult that has been compiled by @lit-labs/compiler, skipping the + * prepare step. + */ +export interface CompiledTemplateResult { + ['_$litType$']: CompiledTemplate; + values: unknown[]; +} +export interface CompiledTemplate extends Omit { + el?: HTMLTemplateElement; + h: TemplateStringsArray; +} +/** + * Interprets a template literal as an HTML template that can efficiently + * render to and update a container. + * + * ```ts + * const header = (title: string) => html`

${title}

`; + * ``` + * + * The `html` tag returns a description of the DOM to render as a value. It is + * lazy, meaning no work is done until the template is rendered. When rendering, + * if a template comes from the same expression as a previously rendered result, + * it's efficiently updated instead of replaced. + */ +export declare const html: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<1>; +/** + * Interprets a template literal as an SVG fragment that can efficiently render + * to and update a container. + * + * ```ts + * const rect = svg``; + * + * const myImage = html` + * + * ${rect} + * `; + * ``` + * + * The `svg` *tag function* should only be used for SVG fragments, or elements + * that would be contained **inside** an `` HTML element. A common error is + * placing an `` *element* in a template tagged with the `svg` tag + * function. The `` element is an HTML element and should be used within a + * template tagged with the {@linkcode html} tag function. + * + * In LitElement usage, it's invalid to return an SVG fragment from the + * `render()` method, as the SVG fragment will be contained within the element's + * shadow root and thus not be properly contained within an `` HTML + * element. + */ +export declare const svg: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<2>; +/** + * Interprets a template literal as MathML fragment that can efficiently render + * to and update a container. + * + * ```ts + * const num = mathml`1`; + * + * const eq = html` + * + * ${num} + * `; + * ``` + * + * The `mathml` *tag function* should only be used for MathML fragments, or + * elements that would be contained **inside** a `` HTML element. A common + * error is placing a `` *element* in a template tagged with the `mathml` + * tag function. The `` element is an HTML element and should be used + * within a template tagged with the {@linkcode html} tag function. + * + * In LitElement usage, it's invalid to return an MathML fragment from the + * `render()` method, as the MathML fragment will be contained within the + * element's shadow root and thus not be properly contained within a `` + * HTML element. + */ +export declare const mathml: (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult<3>; +/** + * A sentinel value that signals that a value was handled by a directive and + * should not be written to the DOM. + */ +export declare const noChange: unique symbol; +/** + * A sentinel value that signals a ChildPart to fully clear its content. + * + * ```ts + * const button = html`${ + * user.isAdmin + * ? html`` + * : nothing + * }`; + * ``` + * + * Prefer using `nothing` over other falsy values as it provides a consistent + * behavior between various expression binding contexts. + * + * In child expressions, `undefined`, `null`, `''`, and `nothing` all behave the + * same and render no nodes. In attribute expressions, `nothing` _removes_ the + * attribute, while `undefined` and `null` will render an empty string. In + * property expressions `nothing` becomes `undefined`. + */ +export declare const nothing: unique symbol; +/** + * Object specifying options for controlling lit-html rendering. Note that + * while `render` may be called multiple times on the same `container` (and + * `renderBefore` reference node) to efficiently update the rendered content, + * only the options passed in during the first render are respected during + * the lifetime of renders to that unique `container` + `renderBefore` + * combination. + */ +export interface RenderOptions { + /** + * An object to use as the `this` value for event listeners. It's often + * useful to set this to the host component rendering a template. + */ + host?: object; + /** + * A DOM node before which to render content in the container. + */ + renderBefore?: ChildNode | null; + /** + * Node used for cloning the template (`importNode` will be called on this + * node). This controls the `ownerDocument` of the rendered DOM, along with + * any inherited context. Defaults to the global `document`. + */ + creationScope?: { + importNode(node: Node, deep?: boolean): Node; + }; + /** + * The initial connected state for the top-level part being rendered. If no + * `isConnected` option is set, `AsyncDirective`s will be connected by + * default. Set to `false` if the initial render occurs in a disconnected tree + * and `AsyncDirective`s should see `isConnected === false` for their initial + * render. The `part.setConnected()` method must be used subsequent to initial + * render to change the connected state of the part. + */ + isConnected?: boolean; +} +/** + * The root DOM node for rendering. + */ +export type RenderRootNode = HTMLElement | SVGElement | DocumentFragment; +export interface DirectiveParent { + _$parent?: DirectiveParent; + _$isConnected: boolean; + __directive?: Directive; + __directives?: Array; +} +declare class Template { + parts: Array; + constructor({ strings, ['_$litType$']: type }: UncompiledTemplateResult, options?: RenderOptions); + /** @nocollapse */ + static createElement(html: TrustedHTML, _options?: RenderOptions): HTMLTemplateElement; +} +export interface Disconnectable { + _$parent?: Disconnectable; + _$disconnectableChildren?: Set; + _$isConnected: boolean; +} +declare function resolveDirective(part: ChildPart | AttributePart | ElementPart, value: unknown, parent?: DirectiveParent, attributeIndex?: number): unknown; +export type { TemplateInstance }; +/** + * An updateable instance of a Template. Holds references to the Parts used to + * update the template instance. + */ +declare class TemplateInstance implements Disconnectable { + _$template: Template; + _$parts: Array; + constructor(template: Template, parent: ChildPart); + get parentNode(): Node; + get _$isConnected(): boolean; + _clone(options: RenderOptions | undefined): Node; + _update(values: Array): void; +} +type AttributeTemplatePart = { + readonly type: typeof ATTRIBUTE_PART; + readonly index: number; + readonly name: string; + readonly ctor: typeof AttributePart; + readonly strings: ReadonlyArray; +}; +type ChildTemplatePart = { + readonly type: typeof CHILD_PART; + readonly index: number; +}; +type ElementTemplatePart = { + readonly type: typeof ELEMENT_PART; + readonly index: number; +}; +type CommentTemplatePart = { + readonly type: typeof COMMENT_PART; + readonly index: number; +}; +/** + * A TemplatePart represents a dynamic part in a template, before the template + * is instantiated. When a template is instantiated Parts are created from + * TemplateParts. + */ +type TemplatePart = ChildTemplatePart | AttributeTemplatePart | ElementTemplatePart | CommentTemplatePart; +export type Part = ChildPart | AttributePart | PropertyPart | BooleanAttributePart | ElementPart | EventPart; +export type { ChildPart }; +declare class ChildPart implements Disconnectable { + readonly type = 2; + readonly options: RenderOptions | undefined; + _$committedValue: unknown; + private _textSanitizer; + get _$isConnected(): boolean; + constructor(startNode: ChildNode, endNode: ChildNode | null, parent: TemplateInstance | ChildPart | undefined, options: RenderOptions | undefined); + /** + * The parent node into which the part renders its content. + * + * A ChildPart's content consists of a range of adjacent child nodes of + * `.parentNode`, possibly bordered by 'marker nodes' (`.startNode` and + * `.endNode`). + * + * - If both `.startNode` and `.endNode` are non-null, then the part's content + * consists of all siblings between `.startNode` and `.endNode`, exclusively. + * + * - If `.startNode` is non-null but `.endNode` is null, then the part's + * content consists of all siblings following `.startNode`, up to and + * including the last child of `.parentNode`. If `.endNode` is non-null, then + * `.startNode` will always be non-null. + * + * - If both `.endNode` and `.startNode` are null, then the part's content + * consists of all child nodes of `.parentNode`. + */ + get parentNode(): Node; + /** + * The part's leading marker node, if any. See `.parentNode` for more + * information. + */ + get startNode(): Node | null; + /** + * The part's trailing marker node, if any. See `.parentNode` for more + * information. + */ + get endNode(): Node | null; + _$setValue(value: unknown, directiveParent?: DirectiveParent): void; + private _insert; + private _commitNode; + private _commitText; + private _commitTemplateResult; + private _commitIterable; +} +/** + * A top-level `ChildPart` returned from `render` that manages the connected + * state of `AsyncDirective`s created throughout the tree below it. + */ +export interface RootPart extends ChildPart { + /** + * Sets the connection state for `AsyncDirective`s contained within this root + * ChildPart. + * + * lit-html does not automatically monitor the connectedness of DOM rendered; + * as such, it is the responsibility of the caller to `render` to ensure that + * `part.setConnected(false)` is called before the part object is potentially + * discarded, to ensure that `AsyncDirective`s have a chance to dispose of + * any resources being held. If a `RootPart` that was previously + * disconnected is subsequently re-connected (and its `AsyncDirective`s should + * re-connect), `setConnected(true)` should be called. + * + * @param isConnected Whether directives within this tree should be connected + * or not + */ + setConnected(isConnected: boolean): void; +} +export type { AttributePart }; +declare class AttributePart implements Disconnectable { + readonly type: typeof ATTRIBUTE_PART | typeof PROPERTY_PART | typeof BOOLEAN_ATTRIBUTE_PART | typeof EVENT_PART; + readonly element: HTMLElement; + readonly name: string; + readonly options: RenderOptions | undefined; + /** + * If this attribute part represents an interpolation, this contains the + * static strings of the interpolation. For single-value, complete bindings, + * this is undefined. + */ + readonly strings?: ReadonlyArray; + protected _sanitizer: ValueSanitizer | undefined; + get tagName(): string; + get _$isConnected(): boolean; + constructor(element: HTMLElement, name: string, strings: ReadonlyArray, parent: Disconnectable, options: RenderOptions | undefined); +} +export type { PropertyPart }; +declare class PropertyPart extends AttributePart { + readonly type = 3; +} +export type { BooleanAttributePart }; +declare class BooleanAttributePart extends AttributePart { + readonly type = 4; +} +/** + * An AttributePart that manages an event listener via add/removeEventListener. + * + * This part works by adding itself as the event listener on an element, then + * delegating to the value passed to it. This reduces the number of calls to + * add/removeEventListener if the listener changes frequently, such as when an + * inline function is used as a listener. + * + * Because event options are passed when adding listeners, we must take case + * to add and remove the part as a listener when the event options change. + */ +export type { EventPart }; +declare class EventPart extends AttributePart { + readonly type = 5; + constructor(element: HTMLElement, name: string, strings: ReadonlyArray, parent: Disconnectable, options: RenderOptions | undefined); + handleEvent(event: Event): void; +} +export type { ElementPart }; +declare class ElementPart implements Disconnectable { + element: Element; + readonly type = 6; + _$committedValue: undefined; + options: RenderOptions | undefined; + constructor(element: Element, parent: Disconnectable, options: RenderOptions | undefined); + get _$isConnected(): boolean; + _$setValue(value: unknown): void; +} +/** + * END USERS SHOULD NOT RELY ON THIS OBJECT. + * + * Private exports for use by other Lit packages, not intended for use by + * external users. + * + * We currently do not make a mangled rollup build of the lit-ssr code. In order + * to keep a number of (otherwise private) top-level exports mangled in the + * client side code, we export a _$LH object containing those members (or + * helper methods for accessing private fields of those members), and then + * re-export them for use in lit-ssr. This keeps lit-ssr agnostic to whether the + * client-side code is being used in `dev` mode or `prod` mode. + * + * This has a unique name, to disambiguate it from private exports in + * lit-element, which re-exports all of lit-html. + * + * @private + */ +export declare const _$LH: { + _boundAttributeSuffix: string; + _marker: string; + _markerMatch: string; + _HTML_RESULT: number; + _getTemplateHtml: (strings: TemplateStringsArray, type: ResultType) => [TrustedHTML, Array]; + _TemplateInstance: typeof TemplateInstance; + _isIterable: (value: unknown) => value is Iterable; + _resolveDirective: typeof resolveDirective; + _ChildPart: typeof ChildPart; + _AttributePart: typeof AttributePart; + _BooleanAttributePart: typeof BooleanAttributePart; + _EventPart: typeof EventPart; + _PropertyPart: typeof PropertyPart; + _ElementPart: typeof ElementPart; +}; +/** + * Renders a value, usually a lit-html TemplateResult, to the container. + * + * This example renders the text "Hello, Zoe!" inside a paragraph tag, appending + * it to the container `document.body`. + * + * ```js + * import {html, render} from 'lit'; + * + * const name = "Zoe"; + * render(html`

Hello, ${name}!

`, document.body); + * ``` + * + * @param value Any [renderable + * value](https://lit.dev/docs/templates/expressions/#child-expressions), + * typically a {@linkcode TemplateResult} created by evaluating a template tag + * like {@linkcode html} or {@linkcode svg}. + * @param container A DOM container to render to. The first render will append + * the rendered value to the container, and subsequent renders will + * efficiently update the rendered value if the same result type was + * previously rendered there. + * @param options See {@linkcode RenderOptions} for options documentation. + * @see + * {@link https://lit.dev/docs/libraries/standalone-templates/#rendering-lit-html-templates| Rendering Lit HTML Templates} + */ +export declare const render: { + (value: unknown, container: RenderRootNode, options?: RenderOptions): RootPart; + setSanitizer: (newSanitizer: SanitizerFactory) => void; + createSanitizer: SanitizerFactory; + _testOnlyClearSanitizerFactoryDoNotCallOrElse: () => void; +}; +//# sourceMappingURL=lit-html.d.ts.map \ No newline at end of file diff --git a/priv/ts/node_modules/lit-html/directive.js b/priv/ts/node_modules/lit-html/directive.js new file mode 100644 index 0000000..55f5580 --- /dev/null +++ b/priv/ts/node_modules/lit-html/directive.js @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t={ATTRIBUTE:1,CHILD:2,PROPERTY:3,BOOLEAN_ATTRIBUTE:4,EVENT:5,ELEMENT:6},e=t=>(...e)=>({_$litDirective$:t,values:e});class i{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,e,i){this._$Ct=t,this._$AM=e,this._$Ci=i}_$AS(t,e){return this.update(t,e)}update(t,e){return this.render(...e)}}export{i as Directive,t as PartType,e as directive}; +//# sourceMappingURL=directive.js.map diff --git a/priv/ts/node_modules/lit-html/directives/unsafe-html.js b/priv/ts/node_modules/lit-html/directives/unsafe-html.js new file mode 100644 index 0000000..e9b6d2e --- /dev/null +++ b/priv/ts/node_modules/lit-html/directives/unsafe-html.js @@ -0,0 +1,7 @@ +import{nothing as t,noChange as i}from"../lit-html.js";import{directive as r,Directive as s,PartType as n}from"../directive.js"; +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */class e extends s{constructor(i){if(super(i),this.it=t,i.type!==n.CHILD)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(r){if(r===t||null==r)return this._t=void 0,this.it=r;if(r===i)return r;if("string"!=typeof r)throw Error(this.constructor.directiveName+"() called with a non-string value");if(r===this.it)return this._t;this.it=r;const s=[r];return s.raw=s,this._t={_$litType$:this.constructor.resultType,strings:s,values:[]}}}e.directiveName="unsafeHTML",e.resultType=1;const o=r(e);export{e as UnsafeHTMLDirective,o as unsafeHTML}; +//# sourceMappingURL=unsafe-html.js.map diff --git a/priv/ts/node_modules/lit-html/lit-html.js b/priv/ts/node_modules/lit-html/lit-html.js new file mode 100644 index 0000000..f082ae1 --- /dev/null +++ b/priv/ts/node_modules/lit-html/lit-html.js @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2017 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +const t=globalThis,i=t=>t,s=t.trustedTypes,e=s?s.createPolicy("lit-html",{createHTML:t=>t}):void 0,h="$lit$",o=`lit$${Math.random().toFixed(9).slice(2)}$`,n="?"+o,r=`<${n}>`,l=document,c=()=>l.createComment(""),a=t=>null===t||"object"!=typeof t&&"function"!=typeof t,u=Array.isArray,d=t=>u(t)||"function"==typeof t?.[Symbol.iterator],f="[ \t\n\f\r]",v=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,_=/-->/g,m=/>/g,p=RegExp(`>|${f}(?:([^\\s"'>=/]+)(${f}*=${f}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),g=/'/g,$=/"/g,y=/^(?:script|style|textarea|title)$/i,x=t=>(i,...s)=>({_$litType$:t,strings:i,values:s}),b=x(1),w=x(2),T=x(3),E=Symbol.for("lit-noChange"),A=Symbol.for("lit-nothing"),C=new WeakMap,P=l.createTreeWalker(l,129);function V(t,i){if(!u(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==e?e.createHTML(i):i}const N=(t,i)=>{const s=t.length-1,e=[];let n,l=2===i?"":3===i?"":"",c=v;for(let i=0;i"===u[0]?(c=n??v,d=-1):void 0===u[1]?d=-2:(d=c.lastIndex-u[2].length,a=u[1],c=void 0===u[3]?p:'"'===u[3]?$:g):c===$||c===g?c=p:c===_||c===m?c=v:(c=p,n=void 0);const x=c===p&&t[i+1].startsWith("/>")?" ":"";l+=c===v?s+r:d>=0?(e.push(a),s.slice(0,d)+h+s.slice(d)+o+x):s+o+(-2===d?i:x)}return[V(t,l+(t[s]||"")+(2===i?"":3===i?"":"")),e]};class S{constructor({strings:t,_$litType$:i},e){let r;this.parts=[];let l=0,a=0;const u=t.length-1,d=this.parts,[f,v]=N(t,i);if(this.el=S.createElement(f,e),P.currentNode=this.el.content,2===i||3===i){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes)}for(;null!==(r=P.nextNode())&&d.length0){r.textContent=s?s.emptyScript:"";for(let s=0;s2||""!==s[0]||""!==s[1]?(this._$AH=Array(s.length-1).fill(new String),this.strings=s):this._$AH=A}_$AI(t,i=this,s,e){const h=this.strings;let o=!1;if(void 0===h)t=M(this,t,i,0),o=!a(t)||t!==this._$AH&&t!==E,o&&(this._$AH=t);else{const e=t;let n,r;for(t=h[0],n=0;n{const e=s?.renderBefore??i;let h=e._$litPart$;if(void 0===h){const t=s?.renderBefore??null;e._$litPart$=h=new k(i.insertBefore(c(),t),t,void 0,s??{})}return h._$AI(t),h};export{j as _$LH,b as html,T as mathml,E as noChange,A as nothing,D as render,w as svg}; +//# sourceMappingURL=lit-html.js.map diff --git a/priv/ts/node_modules/lit-html/package.json b/priv/ts/node_modules/lit-html/package.json new file mode 100644 index 0000000..290e3e3 --- /dev/null +++ b/priv/ts/node_modules/lit-html/package.json @@ -0,0 +1,629 @@ +{ + "name": "lit-html", + "version": "3.3.3", + "description": "HTML templates literals in JavaScript", + "license": "BSD-3-Clause", + "repository": { + "type": "git", + "url": "git+https://github.com/lit/lit.git", + "directory": "packages/lit-html" + }, + "author": "Google LLC", + "homepage": "https://lit.dev/", + "main": "lit-html.js", + "type": "module", + "exports": { + ".": { + "types": "./development/lit-html.d.ts", + "browser": { + "development": "./development/lit-html.js", + "default": "./lit-html.js" + }, + "node": { + "development": "./node/development/lit-html.js", + "default": "./node/lit-html.js" + }, + "development": "./development/lit-html.js", + "default": "./lit-html.js" + }, + "./async-directive.js": { + "types": "./development/async-directive.d.ts", + "browser": { + "development": "./development/async-directive.js", + "default": "./async-directive.js" + }, + "node": { + "development": "./node/development/async-directive.js", + "default": "./node/async-directive.js" + }, + "development": "./development/async-directive.js", + "default": "./async-directive.js" + }, + "./directive-helpers.js": { + "types": "./development/directive-helpers.d.ts", + "browser": { + "development": "./development/directive-helpers.js", + "default": "./directive-helpers.js" + }, + "node": { + "development": "./node/development/directive-helpers.js", + "default": "./node/directive-helpers.js" + }, + "development": "./development/directive-helpers.js", + "default": "./directive-helpers.js" + }, + "./directive.js": { + "types": "./development/directive.d.ts", + "browser": { + "development": "./development/directive.js", + "default": "./directive.js" + }, + "node": { + "development": "./node/development/directive.js", + "default": "./node/directive.js" + }, + "development": "./development/directive.js", + "default": "./directive.js" + }, + "./directives/async-append.js": { + "types": "./development/directives/async-append.d.ts", + "browser": { + "development": "./development/directives/async-append.js", + "default": "./directives/async-append.js" + }, + "node": { + "development": "./node/development/directives/async-append.js", + "default": "./node/directives/async-append.js" + }, + "development": "./development/directives/async-append.js", + "default": "./directives/async-append.js" + }, + "./directives/async-replace.js": { + "types": "./development/directives/async-replace.d.ts", + "browser": { + "development": "./development/directives/async-replace.js", + "default": "./directives/async-replace.js" + }, + "node": { + "development": "./node/development/directives/async-replace.js", + "default": "./node/directives/async-replace.js" + }, + "development": "./development/directives/async-replace.js", + "default": "./directives/async-replace.js" + }, + "./directives/cache.js": { + "types": "./development/directives/cache.d.ts", + "browser": { + "development": "./development/directives/cache.js", + "default": "./directives/cache.js" + }, + "node": { + "development": "./node/development/directives/cache.js", + "default": "./node/directives/cache.js" + }, + "development": "./development/directives/cache.js", + "default": "./directives/cache.js" + }, + "./directives/choose.js": { + "types": "./development/directives/choose.d.ts", + "browser": { + "development": "./development/directives/choose.js", + "default": "./directives/choose.js" + }, + "node": { + "development": "./node/development/directives/choose.js", + "default": "./node/directives/choose.js" + }, + "development": "./development/directives/choose.js", + "default": "./directives/choose.js" + }, + "./directives/class-map.js": { + "types": "./development/directives/class-map.d.ts", + "browser": { + "development": "./development/directives/class-map.js", + "default": "./directives/class-map.js" + }, + "node": { + "development": "./node/development/directives/class-map.js", + "default": "./node/directives/class-map.js" + }, + "development": "./development/directives/class-map.js", + "default": "./directives/class-map.js" + }, + "./directives/guard.js": { + "types": "./development/directives/guard.d.ts", + "browser": { + "development": "./development/directives/guard.js", + "default": "./directives/guard.js" + }, + "node": { + "development": "./node/development/directives/guard.js", + "default": "./node/directives/guard.js" + }, + "development": "./development/directives/guard.js", + "default": "./directives/guard.js" + }, + "./directives/if-defined.js": { + "types": "./development/directives/if-defined.d.ts", + "browser": { + "development": "./development/directives/if-defined.js", + "default": "./directives/if-defined.js" + }, + "node": { + "development": "./node/development/directives/if-defined.js", + "default": "./node/directives/if-defined.js" + }, + "development": "./development/directives/if-defined.js", + "default": "./directives/if-defined.js" + }, + "./directives/join.js": { + "types": "./development/directives/join.d.ts", + "browser": { + "development": "./development/directives/join.js", + "default": "./directives/join.js" + }, + "node": { + "development": "./node/development/directives/join.js", + "default": "./node/directives/join.js" + }, + "development": "./development/directives/join.js", + "default": "./directives/join.js" + }, + "./directives/keyed.js": { + "types": "./development/directives/keyed.d.ts", + "browser": { + "development": "./development/directives/keyed.js", + "default": "./directives/keyed.js" + }, + "node": { + "development": "./node/development/directives/keyed.js", + "default": "./node/directives/keyed.js" + }, + "development": "./development/directives/keyed.js", + "default": "./directives/keyed.js" + }, + "./directives/live.js": { + "types": "./development/directives/live.d.ts", + "browser": { + "development": "./development/directives/live.js", + "default": "./directives/live.js" + }, + "node": { + "development": "./node/development/directives/live.js", + "default": "./node/directives/live.js" + }, + "development": "./development/directives/live.js", + "default": "./directives/live.js" + }, + "./directives/map.js": { + "types": "./development/directives/map.d.ts", + "browser": { + "development": "./development/directives/map.js", + "default": "./directives/map.js" + }, + "node": { + "development": "./node/development/directives/map.js", + "default": "./node/directives/map.js" + }, + "development": "./development/directives/map.js", + "default": "./directives/map.js" + }, + "./directives/range.js": { + "types": "./development/directives/range.d.ts", + "browser": { + "development": "./development/directives/range.js", + "default": "./directives/range.js" + }, + "node": { + "development": "./node/development/directives/range.js", + "default": "./node/directives/range.js" + }, + "development": "./development/directives/range.js", + "default": "./directives/range.js" + }, + "./directives/ref.js": { + "types": "./development/directives/ref.d.ts", + "browser": { + "development": "./development/directives/ref.js", + "default": "./directives/ref.js" + }, + "node": { + "development": "./node/development/directives/ref.js", + "default": "./node/directives/ref.js" + }, + "development": "./development/directives/ref.js", + "default": "./directives/ref.js" + }, + "./directives/repeat.js": { + "types": "./development/directives/repeat.d.ts", + "browser": { + "development": "./development/directives/repeat.js", + "default": "./directives/repeat.js" + }, + "node": { + "development": "./node/development/directives/repeat.js", + "default": "./node/directives/repeat.js" + }, + "development": "./development/directives/repeat.js", + "default": "./directives/repeat.js" + }, + "./directives/style-map.js": { + "types": "./development/directives/style-map.d.ts", + "browser": { + "development": "./development/directives/style-map.js", + "default": "./directives/style-map.js" + }, + "node": { + "development": "./node/development/directives/style-map.js", + "default": "./node/directives/style-map.js" + }, + "development": "./development/directives/style-map.js", + "default": "./directives/style-map.js" + }, + "./directives/template-content.js": { + "types": "./development/directives/template-content.d.ts", + "browser": { + "development": "./development/directives/template-content.js", + "default": "./directives/template-content.js" + }, + "node": { + "development": "./node/development/directives/template-content.js", + "default": "./node/directives/template-content.js" + }, + "development": "./development/directives/template-content.js", + "default": "./directives/template-content.js" + }, + "./directives/unsafe-html.js": { + "types": "./development/directives/unsafe-html.d.ts", + "browser": { + "development": "./development/directives/unsafe-html.js", + "default": "./directives/unsafe-html.js" + }, + "node": { + "development": "./node/development/directives/unsafe-html.js", + "default": "./node/directives/unsafe-html.js" + }, + "development": "./development/directives/unsafe-html.js", + "default": "./directives/unsafe-html.js" + }, + "./directives/unsafe-mathml.js": { + "types": "./development/directives/unsafe-mathml.d.ts", + "browser": { + "development": "./development/directives/unsafe-mathml.js", + "default": "./directives/unsafe-mathml.js" + }, + "node": { + "development": "./node/development/directives/unsafe-mathml.js", + "default": "./node/directives/unsafe-mathml.js" + }, + "development": "./development/directives/unsafe-mathml.js", + "default": "./directives/unsafe-mathml.js" + }, + "./directives/unsafe-svg.js": { + "types": "./development/directives/unsafe-svg.d.ts", + "browser": { + "development": "./development/directives/unsafe-svg.js", + "default": "./directives/unsafe-svg.js" + }, + "node": { + "development": "./node/development/directives/unsafe-svg.js", + "default": "./node/directives/unsafe-svg.js" + }, + "development": "./development/directives/unsafe-svg.js", + "default": "./directives/unsafe-svg.js" + }, + "./directives/until.js": { + "types": "./development/directives/until.d.ts", + "browser": { + "development": "./development/directives/until.js", + "default": "./directives/until.js" + }, + "node": { + "development": "./node/development/directives/until.js", + "default": "./node/directives/until.js" + }, + "development": "./development/directives/until.js", + "default": "./directives/until.js" + }, + "./directives/when.js": { + "types": "./development/directives/when.d.ts", + "browser": { + "development": "./development/directives/when.js", + "default": "./directives/when.js" + }, + "node": { + "development": "./node/development/directives/when.js", + "default": "./node/directives/when.js" + }, + "development": "./development/directives/when.js", + "default": "./directives/when.js" + }, + "./polyfill-support.js": { + "types": "./development/polyfill-support.d.ts", + "browser": { + "development": "./development/polyfill-support.js", + "default": "./polyfill-support.js" + }, + "node": { + "development": "./node/development/polyfill-support.js", + "default": "./node/polyfill-support.js" + }, + "development": "./development/polyfill-support.js", + "default": "./polyfill-support.js" + }, + "./private-ssr-support.js": { + "types": "./development/private-ssr-support.d.ts", + "browser": { + "development": "./development/private-ssr-support.js", + "default": "./private-ssr-support.js" + }, + "node": { + "development": "./node/development/private-ssr-support.js", + "default": "./node/private-ssr-support.js" + }, + "development": "./development/private-ssr-support.js", + "default": "./private-ssr-support.js" + }, + "./static.js": { + "types": "./development/static.d.ts", + "browser": { + "development": "./development/static.js", + "default": "./static.js" + }, + "node": { + "development": "./node/development/static.js", + "default": "./node/static.js" + }, + "development": "./development/static.js", + "default": "./static.js" + }, + "./is-server.js": { + "types": "./development/is-server.d.ts", + "browser": { + "development": "./development/is-server.js", + "default": "./is-server.js" + }, + "node": { + "development": "./node/development/is-server.js", + "default": "./node/is-server.js" + }, + "development": "./development/is-server.js", + "default": "./is-server.js" + } + }, + "scripts": { + "build": "wireit", + "build:ts": "wireit", + "build:ts:types": "wireit", + "build:compiled:ts:tests": "wireit", + "build:rollup": "wireit", + "build:version-stability-test": "wireit", + "check-version": "wireit", + "checksize": "wireit", + "prepublishOnly": "npm run check-version", + "test": "wireit", + "test:dev": "wireit", + "test:prod": "wireit", + "test:node": "wireit", + "test:node-dev": "wireit", + "test:dev:compiled": "wireit" + }, + "files": [ + "/async-directive.{d.ts,d.ts.map,js,js.map}", + "/directive-helpers.{d.ts,d.ts.map,js,js.map}", + "/directive.{d.ts,d.ts.map,js,js.map}", + "/lit-html.{d.ts,d.ts.map,js,js.map}", + "/polyfill-support.{d.ts,d.ts.map,js,js.map}", + "/private-ssr-support.{d.ts,d.ts.map,js,js.map}", + "/static.{d.ts,d.ts.map,js,js.map}", + "/is-server.{d.ts,d.ts.map,js,js.map}", + "/development/", + "!/development/test/", + "/directives/", + "/node/" + ], + "wireit": { + "build": { + "dependencies": [ + "build:rollup", + "build:ts", + "build:ts:types", + "build:version-stability-test" + ] + }, + "build:ts": { + "#comment": "Note this also builds polyfill-support via a TypeScript project reference.", + "command": "tsc --build --pretty", + "dependencies": [ + "../labs/testing:build:ts:utils" + ], + "clean": "if-file-deleted", + "files": [ + "src/**/*.ts", + "tsconfig.json", + "tsconfig.polyfill-support.json" + ], + "output": [ + "development/**/*.{js,js.map,d.ts,d.ts.map}", + "tsconfig.tsbuildinfo", + "tsconfig.polyfill-support.tsbuildinfo" + ] + }, + "build:ts:types": { + "command": "treemirror development . \"**/*.d.ts{,.map}\"", + "dependencies": [ + "../internal-scripts:build", + "build:ts" + ], + "files": [], + "output": [ + "*.d.ts{,.map}", + "directives/*.d.ts{,.map}" + ] + }, + "build:compiled:ts:tests": { + "#comment": "Compile the test directory with the lit template compiler.", + "command": "node ../labs/compiler/test/compile_lit_html_tests.js", + "dependencies": [ + "../labs/compiler:build" + ], + "files": [ + "src/test/**/*.ts", + "tsconfig.json", + "tsconfig.compiled-tests.json" + ], + "output": [ + "compiled/**/*.{js,js.map,d.ts,d.ts.map}" + ] + }, + "build:rollup": { + "command": "rollup -c", + "dependencies": [ + "build:ts" + ], + "files": [ + "rollup.config.js", + "../../rollup-common.js", + "src/test/*_test.html", + "src/test/polyfill-support/*_test.html" + ], + "output": [ + "async-directive.js{,.map}", + "directive-helpers.js{,.map}", + "directive.js{,.map}", + "lit-html.js{,.map}", + "polyfill-support.js{,.map}", + "private-ssr-support.js{,.map}", + "static.js{,.map}", + "is-server.js{,.map}", + "directives/*.js{,.map}", + "test/*_test.html", + "development/test/*_test.html", + "test/polyfill-support/*_test.html", + "development/test/polyfill-support/*_test.html", + "node/" + ] + }, + "build:version-stability-test": { + "command": "rollup -c rollup-version-stability-test.config.js", + "dependencies": [ + "build:ts" + ], + "files": [ + "rollup-version-stability-test.config.js", + "rollup.config.js", + "../../rollup-common.js" + ], + "output": [ + "version-stability-build" + ] + }, + "checksize": { + "command": "rollup -c --environment=CHECKSIZE", + "dependencies": [ + "build:ts" + ], + "files": [ + "rollup.config.js", + "../../rollup-common.js" + ], + "output": [] + }, + "check-version": { + "command": "node scripts/check-version-tracker.js", + "files": [ + "scripts/check-version-tracker.js", + "package.json", + "src/lit-html.ts" + ], + "output": [] + }, + "test": { + "dependencies": [ + "test:dev", + "test:prod", + "test:node", + "test:node-dev", + "test:dev:compiled", + "check-version" + ] + }, + "test:dev": { + "command": "MODE=dev node ../tests/run-web-tests.js \"development/**/*_test.(js|html)\" --config ../tests/web-test-runner.config.js", + "dependencies": [ + "build:ts", + "../tests:build" + ], + "env": { + "BROWSERS": { + "external": true + } + }, + "files": [], + "output": [] + }, + "test:prod": { + "command": "MODE=prod node ../tests/run-web-tests.js \"development/**/*_test.(js|html)\" --config ../tests/web-test-runner.config.js", + "dependencies": [ + "build:rollup", + "build:ts", + "build:version-stability-test", + "../tests:build" + ], + "env": { + "BROWSERS": { + "external": true + } + }, + "files": [], + "output": [] + }, + "test:node": { + "command": "node development/test/node-imports.js", + "dependencies": [ + "build:ts", + "build:rollup" + ], + "files": [], + "output": [] + }, + "test:node-dev": { + "command": "node --conditions=development development/test/node-imports.js", + "dependencies": [ + "build:ts", + "build:rollup" + ], + "files": [], + "output": [] + }, + "test:dev:compiled": { + "command": "node ../tests/run-web-tests.js \"compiled/**/*_test.(js|html)\" --config ../tests/web-test-runner.config.js", + "dependencies": [ + "build:compiled:ts:tests", + "../tests:build" + ], + "env": { + "BROWSERS": { + "external": true + }, + "MODE": "dev" + }, + "files": [], + "output": [] + } + }, + "dependencies": { + "@types/trusted-types": "^2.0.2" + }, + "devDependencies": { + "@lit-internal/scripts": "^1.0.1", + "@lit-labs/testing": "^0.2.2", + "@webcomponents/shadycss": "^1.8.0", + "@webcomponents/template": "^1.4.4", + "@webcomponents/webcomponentsjs": "^2.8.0" + }, + "typings": "lit-html.d.ts", + "directories": { + "test": "test" + } +} diff --git a/priv/ts/npm.lock b/priv/ts/npm.lock new file mode 100644 index 0000000..59e1ac7 --- /dev/null +++ b/priv/ts/npm.lock @@ -0,0 +1,31 @@ +{ + "lockfileVersion": 1, + "packages": { + "@types/trusted-types": { + "dependencies": {}, + "has_install_script": false, + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "version": "2.0.7" + }, + "lit-html": { + "dependencies": { + "@types/trusted-types": "^2.0.2" + }, + "has_install_script": false, + "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==", + "optional_dependencies": {}, + "tarball": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz", + "version": "3.3.3" + } + }, + "policy": { + "allow_registry_redirects": false, + "allowed_registries": [ + "https://registry.npmjs.org" + ], + "block_exotic_subdeps": true, + "exotic_deps": [] + } +} diff --git a/priv/ts/package.json b/priv/ts/package.json new file mode 100644 index 0000000..24d2899 --- /dev/null +++ b/priv/ts/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "dependencies": { + "lit-html": "3.3.3" + } +} diff --git a/test/volt/priv/vendor_test.exs b/test/volt/priv/vendor_test.exs new file mode 100644 index 0000000..d408ed3 --- /dev/null +++ b/test/volt/priv/vendor_test.exs @@ -0,0 +1,67 @@ +defmodule Volt.Priv.VendorTest do + use ExUnit.Case, async: true + + alias Volt.Priv.Vendor + + @moduletag :tmp_dir + + test "keeps the browser modules and declarations the sources reach", %{tmp_dir: tmp_dir} do + sources = Path.join(tmp_dir, "priv/ts") + node_modules = Path.join(tmp_dir, "install/node_modules") + + write!(sources, %{ + "app.ts" => """ + import { widget } from 'kit' + import 'kit/extra.js' + import { compile } from 'runtime-only' + import { local } from './local' + """, + "local.ts" => "export const local = 1\n" + }) + + write!(node_modules, %{ + "kit/package.json" => + Jason.encode!(%{ + name: "kit", + exports: %{ + "." => %{types: "./types/kit.d.ts", browser: "./kit.js", default: "./node/kit.js"}, + "./extra.js" => "./extra.js" + } + }), + "kit/LICENSE" => "MIT", + "kit/kit.js" => "import { helper } from './helper.js'\nexport const widget = helper\n", + "kit/helper.js" => "export const helper = 1\n", + "kit/node/kit.js" => "export const widget = 1\n", + "kit/extra.js" => "export {}\n", + "kit/extra.d.ts" => "export {}\n", + "kit/unused.js" => "export {}\n", + "kit/kit.js.map" => "{}", + "kit/types/kit.d.ts" => + "import type { Policy } from 'policy/lib/index.js'\nexport declare const widget: Policy\n", + "@types/policy/package.json" => Jason.encode!(%{name: "@types/policy"}), + "@types/policy/lib/index.d.ts" => "export type Policy = string\n", + "runtime-only/package.json" => Jason.encode!(%{name: "runtime-only", main: "index.js"}), + "runtime-only/index.js" => "export const compile = 1\n" + }) + + assert Vendor.reachable(sources, %{"kit" => "1.0.0"}, node_modules) == [ + "@types/policy/lib/index.d.ts", + "@types/policy/package.json", + "kit/LICENSE", + "kit/extra.d.ts", + "kit/extra.js", + "kit/helper.js", + "kit/kit.js", + "kit/package.json", + "kit/types/kit.d.ts" + ] + end + + defp write!(root, files) do + for {path, contents} <- files do + path = Path.join(root, path) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, contents) + end + end +end From 4c1933d8fb7826628e1c74b67f3e5e3013f8168c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 30 Sep 2026 12:19:48 +0400 Subject: [PATCH 074/128] Show a diagnostic's stacktrace in the error overlay --- lib/volt/dev/error.ex | 22 +- priv/ts/client/overlay.ts | 8 + .../node_modules/@types/trusted-types/LICENSE | 21 - .../@types/trusted-types/lib/index.d.ts | 64 -- .../@types/trusted-types/package.json | 45 -- priv/ts/node_modules/lit-html/LICENSE | 28 - .../lit-html/development/directive.d.ts | 66 -- .../development/directives/unsafe-html.d.ts | 27 - .../lit-html/development/lit-html.d.ts | 621 ----------------- priv/ts/node_modules/lit-html/directive.js | 7 - .../lit-html/directives/unsafe-html.js | 7 - priv/ts/node_modules/lit-html/lit-html.js | 7 - priv/ts/node_modules/lit-html/package.json | 629 ------------------ test/volt/dev/error_test.exs | 9 + 14 files changed, 36 insertions(+), 1525 deletions(-) delete mode 100644 priv/ts/node_modules/@types/trusted-types/LICENSE delete mode 100644 priv/ts/node_modules/@types/trusted-types/lib/index.d.ts delete mode 100644 priv/ts/node_modules/@types/trusted-types/package.json delete mode 100644 priv/ts/node_modules/lit-html/LICENSE delete mode 100644 priv/ts/node_modules/lit-html/development/directive.d.ts delete mode 100644 priv/ts/node_modules/lit-html/development/directives/unsafe-html.d.ts delete mode 100644 priv/ts/node_modules/lit-html/development/lit-html.d.ts delete mode 100644 priv/ts/node_modules/lit-html/directive.js delete mode 100644 priv/ts/node_modules/lit-html/directives/unsafe-html.js delete mode 100644 priv/ts/node_modules/lit-html/lit-html.js delete mode 100644 priv/ts/node_modules/lit-html/package.json diff --git a/lib/volt/dev/error.ex b/lib/volt/dev/error.ex index d1438e7..a207426 100644 --- a/lib/volt/dev/error.ex +++ b/lib/volt/dev/error.ex @@ -20,7 +20,8 @@ defmodule Volt.Dev.Error do column: pos_integer() | nil, hint: String.t() | nil, frame: String.t() | nil, - frame_html: String.t() | nil + frame_html: String.t() | nil, + stack: String.t() | nil } @doc """ @@ -43,7 +44,21 @@ defmodule Volt.Dev.Error do {line, column} = position(diagnostic[:position]) hint = if is_binary(diagnostic[:details]), do: diagnostic[:details] - [entry(message, opts, file: diagnostic[:file], line: line, column: column, hint: hint)] + stack = + case diagnostic[:stacktrace] do + [_ | _] = stacktrace -> Exception.format_stacktrace(stacktrace) + _none -> nil + end + + [ + entry(message, opts, + file: diagnostic[:file], + line: line, + column: column, + hint: hint, + stack: stack + ) + ] end def entries(message, opts) when is_binary(message), do: [entry(message, opts, [])] @@ -62,7 +77,8 @@ defmodule Volt.Dev.Error do column: column, hint: fields[:hint], frame: frame, - frame_html: frame_html + frame_html: frame_html, + stack: fields[:stack] } end diff --git a/priv/ts/client/overlay.ts b/priv/ts/client/overlay.ts index 3d3edf6..8fe7f91 100644 --- a/priv/ts/client/overlay.ts +++ b/priv/ts/client/overlay.ts @@ -9,6 +9,7 @@ export type VoltError = { hint?: string | null frame?: string | null frame_html?: string | null + stack?: string | null } type VoltErrorOverlayOptions = { @@ -85,6 +86,12 @@ const styles = html`') + + expect(morphDocument(next, '')).toBe(false) +}) + +test('leaves elements owned by client code alone', () => { + document.body.innerHTML = + '

first

3 clicks
' + + const island = document.querySelector('[data-island]') + + const next = page('

second

') + + expect(morphDocument(next, '[data-island]')).toBe(true) + expect(document.getElementById('text')?.textContent).toBe('second') + expect(document.querySelector('[data-island]')).toBe(island) + expect(island?.innerHTML).toBe('3 clicks') + expect(island?.hasAttribute('data-mounted')).toBe(true) +}) + +test('refuses when what the server renders for an owned element differs', () => { + document.body.innerHTML = '
' + + const changed = page('
') + const added = page('
') + + expect(morphDocument(changed, '[data-island]')).toBe(false) + expect(morphDocument(added, '[data-island]')).toBe(false) +}) diff --git a/test/volt/dev_server/requests_test.exs b/test/volt/dev_server/requests_test.exs index 786ff83..64ee347 100644 --- a/test/volt/dev_server/requests_test.exs +++ b/test/volt/dev_server/requests_test.exs @@ -331,6 +331,22 @@ defmodule Volt.DevServer.RequestsTest do assert get_resp_header(conn, "etag") != [etag] end + test "marks pages for in-place updates when the server opts in" do + page = "" + + send_page = fn opts -> + call_dev_server("/page", opts) + |> put_resp_content_type("text/html") + |> send_resp(200, page) + end + + refute send_page.([]).resp_body =~ "data-volt-morph" + assert send_page.(morph: true).resp_body =~ ~s( data-volt-morph="") + + assert send_page.(morph: [preserve: "[data-island]"]).resp_body =~ + ~s( data-volt-morph="[data-island]") + end + test "error pages get the HMR client without an entity tag" do conn = call_dev_server("/page") diff --git a/test/volt/integration/hmr_test.exs b/test/volt/integration/hmr_test.exs index 22a065f..901eba2 100644 --- a/test/volt/integration/hmr_test.exs +++ b/test/volt/integration/hmr_test.exs @@ -10,11 +10,19 @@ defmodule Volt.Integration.TestPlug do @impl true # Pages rendered from content files, sent through the dev server as an app's # own pages are. - def call(%Conn{request_path: "/doc/" <> name} = conn, opts) do - content = File.read!(Path.join(opts[:root] <> "-content", name <> ".md")) + def call(%Conn{request_path: "/doc/" <> name} = conn, opts), + do: document(conn, name, opts[:root], opts[:dev_server]) + + def call(%Conn{request_path: "/morph/" <> name} = conn, opts), + do: document(conn, name, opts[:root], opts[:morph_dev_server]) + + def call(conn, opts), do: asset(conn, opts) + + defp document(conn, name, root, dev_server) do + content = File.read!(Path.join(root <> "-content", name <> ".md")) conn - |> Volt.DevServer.call(opts[:dev_server]) + |> Volt.DevServer.call(dev_server) |> Conn.put_resp_content_type("text/html") |> Conn.send_resp(200, """ @@ -29,7 +37,7 @@ defmodule Volt.Integration.TestPlug do """) end - def call(conn, opts) do + defp asset(conn, opts) do if String.ends_with?(conn.request_path, ".html") or conn.request_path == "/" do file = case conn.request_path do @@ -138,7 +146,9 @@ defmodule Volt.Integration.HMRTest do """) dev_server_opts = Volt.DevServer.init(root: @fixture_dir, prefix: "/assets") - plug_opts = %{root: @fixture_dir, dev_server: dev_server_opts} + morph_opts = Volt.DevServer.init(root: @fixture_dir, prefix: "/assets", morph: true) + + plug_opts = %{root: @fixture_dir, dev_server: dev_server_opts, morph_dev_server: morph_opts} {:ok, server} = Bandit.start_link( @@ -479,6 +489,54 @@ defmodule Volt.Integration.HMRTest do GenServer.stop(watcher) end + test "pages that opt in are patched in place instead of reloaded", %{frame: frame} do + content_dir = @fixture_dir <> "-content" + File.rm_rf!(content_dir) + File.mkdir_p!(content_dir) + on_exit(fn -> File.rm_rf!(content_dir) end) + File.write!(Path.join(content_dir, "first.md"), "first page") + + {:ok, _} = Frame.goto(frame.guid, url: base_url("/morph/first"), timeout: 10_000) + assert {:ok, "1"} = eval_until(frame, "document.body.dataset.loads", "1") + + # State a reload would lose. + {:ok, _} = eval_poll(frame, "(window.voltKept = 'kept')") + + {:ok, watcher} = + Volt.Watcher.start_link( + root: @fixture_dir, + reload_dirs: [content_dir], + name: String.to_atom("volt_integration_morph_#{System.unique_integer([:positive])}") + ) + + Process.sleep(150) + File.write!(Path.join(content_dir, "first.md"), "first page, edited") + + assert {:ok, "first page, edited"} = + eval_until( + frame, + "document.getElementById('content')?.textContent", + "first page, edited" + ) + + assert {:ok, "kept"} = eval_poll(frame, "window.voltKept") + assert {:ok, "1"} = eval_poll(frame, "document.body.dataset.loads") + + # The patched page revalidates against its new HTML. + File.write!(Path.join(content_dir, "first.md"), "first page, edited twice") + + assert {:ok, "first page, edited twice"} = + eval_until( + frame, + "document.getElementById('content')?.textContent", + "first page, edited twice" + ) + + assert {:ok, "kept"} = eval_poll(frame, "window.voltKept") + + GenServer.stop(watcher) + end + test "non-accepted module updates trigger a full reload", %{frame: frame} do write_fixture("full_reload.ts", """ const count = Number(sessionStorage.getItem('voltReloadCount') ?? '0') + 1 From a0d406334d8cbaa67fc04555608a198d64ec942c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 15:31:25 +0400 Subject: [PATCH 106/128] Release Volt 0.20.0 --- CHANGELOG.md | 12 +++++------- README.md | 2 +- guides/introduction/getting-started.md | 4 ++-- guides/migration/from-esbuild.md | 2 +- mix.exs | 2 +- 5 files changed, 10 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d52ccd..ee13139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.20.0 - 2026-10-03 ### Breaking changes @@ -18,16 +18,14 @@ ### Added +- Changes in `reload_dirs` reload only the pages whose HTML changed. The watcher broadcasts a `document` update for them instead of a `full` one. Pages carry an entity tag of their rendered HTML; on a change the dev client revalidates with `If-None-Match` and reloads unless the server answers `304 Not Modified`. Editing one post no longer reloads every open tab, and a save that does not change a page's output reloads nothing. This covers every successful HTML response that passes through `Volt.DevServer`, including pages that already load the dev client, so site generators built on Volt need no change. `Volt.HMR.document_update/2` sends the same request from other packages, and `Volt.HMR.Document` describes the contract for servers that send HTML some other way. +- `config :volt, :server, morph: true` patches a changed page in place instead of reloading it, so scroll position, focus and JavaScript state survive edits to text and classes. The client reloads as before when scripts or stylesheets differ. `morph: [preserve: selector]` names elements owned by client code, such as mounted components, which are left alone. The client dispatches `volt:document-updated` after a patch. +- Stylesheet updates no longer flash unstyled content. The dev client adds a `` for the updated stylesheet and removes the previous one once the new one has loaded, instead of changing `href` in place. +- The dev client reloads the page once when several updates ask for a reload at the same time, such as a template that is also a Tailwind source. - The dev server converts local CommonJS and UMD files, such as Phoenix's vendored `topbar.js`, to ES modules, so `import topbar from "../vendor/topbar"` works in development as it does in production builds. `.cjs` and `.cts` files, which the dev server did not serve, are converted the same way. - Relative `watch_ignored` patterns also resolve from the project directory, so `_build/**` matches a watched directory inside `_build`. - - Volt's client types declare `import.meta.env`, so TypeScript projects no longer need their own `ImportMeta` declaration for `MODE`, `DEV`, `PROD` and exposed variables. `mix igniter.install volt` adds `env.d.ts` to configurations that list declaration files explicitly. -- Stylesheet updates no longer flash unstyled content. The dev client adds a `` for the updated stylesheet and removes the previous one once the new one has loaded, instead of changing `href` in place. -- `config :volt, :server, morph: true` patches a changed page in place instead of reloading it, so scroll position, focus and JavaScript state survive edits to text and classes. The client reloads as before when scripts or stylesheets differ. `morph: [preserve: selector]` names elements owned by client code, such as mounted components, which are left alone. The client dispatches `volt:document-updated` after a patch. -- Changes in `reload_dirs` reload only the pages whose HTML changed. Pages carry an entity tag of their rendered HTML; on a change the dev client revalidates with `If-None-Match` and reloads unless the server answers `304 Not Modified`. Editing one post no longer reloads every open tab, and a save that does not change a page's output reloads nothing. This covers every successful HTML response that passes through `Volt.DevServer`, including pages that already load the dev client, so site generators built on Volt need no change. `Volt.HMR.document_update/2` sends the same request from other packages, and `Volt.HMR.Document` describes the contract for servers that send HTML some other way. -- The dev client reloads the page once when several updates ask for a reload at the same time, such as a template that is also a Tailwind source. - ### Fixed - Collect every test generated by `test.each` and `describe.each`. Source lines were matched to tests by position, and tests without a detected line were dropped: a five-case table followed by two tests collected as two tests. Each case now maps to the line of its table, and tests registered in ways the source does not show, such as in a loop, are kept without a line. diff --git a/README.md b/README.md index c1b5733..bf2bd1e 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Or add the dep manually: ```elixir def deps do - [{:volt, "~> 0.19"}] + [{:volt, "~> 0.20"}] end ``` diff --git a/guides/introduction/getting-started.md b/guides/introduction/getting-started.md index e588964..cf20852 100644 --- a/guides/introduction/getting-started.md +++ b/guides/introduction/getting-started.md @@ -7,7 +7,7 @@ mix igniter.install volt ``` The installer: -- Adds `{:volt, "~> 0.19"}` to `mix.exs` +- Adds `{:volt, "~> 0.20"}` to `mix.exs` - Configures build settings in `config/config.exs` - Adds lint config to `config/config.exs` - Adds the `Volt.Formatter` plugin and formatter options to `.formatter.exs` @@ -28,7 +28,7 @@ Add Volt to your dependencies: ```elixir def deps do - [{:volt, "~> 0.19"}] + [{:volt, "~> 0.20"}] end ``` diff --git a/guides/migration/from-esbuild.md b/guides/migration/from-esbuild.md index c1a6681..819d1c9 100644 --- a/guides/migration/from-esbuild.md +++ b/guides/migration/from-esbuild.md @@ -22,7 +22,7 @@ Remove from `mix.exs`: Add: ```elixir -{:volt, "~> 0.19"} +{:volt, "~> 0.20"} ``` ### 2. Replace Config diff --git a/mix.exs b/mix.exs index 58427bf..2f840a9 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Volt.MixProject do use Mix.Project - @version "0.19.4" + @version "0.20.0" @source_url "https://github.com/elixir-volt/volt" def project do From 2f8d587c109c0132ff6703ab652898c367764f0b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 16:42:29 +0400 Subject: [PATCH 107/128] Require vize 0.17 in 0.20.0 vize 0.17 replaces Vize.vapor_split/1 with Vize.split_template/2 and changes its error shape. Volt calls neither; the functions it uses (parse_sfc, compile_sfc, Vize.SFC.collect_template_assets and Vize.CSS) are unchanged, and the suite passes on 0.17.0. PhoenixVapor needs vize 0.17, and a Volt that pins ~> 0.16.0 cannot be installed alongside it. Volt follows vize's latest minor. --- CHANGELOG.md | 4 ++++ mix.exs | 2 +- mix.lock | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee13139..c849de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ - External modules stay as imports in `:esm` and `:cjs` output, as in Rollup, Rolldown, esbuild and Bun. With `format: :esm, external: ["phoenix"]`, `import { Socket } from "phoenix"` used to become `const { Socket } = Phoenix;`; it is now left for the browser or host to resolve, for example through an import map. `:cjs` output uses `require("phoenix")`. IIFE output, the default, still reads externals from globals. The global names in `external: %{"phoenix" => "Phoenix"}` apply to IIFE output only. ESM builds that relied on page globals need an import map or `format: :iife`. - `Volt.JS.Format.load_config/1` takes the `.formatter.exs` options instead of reading the application environment. +### Compatibility + +- Require vize 0.17. Volt's use of vize is unchanged: 0.17 replaces `Vize.vapor_split/1` with `Vize.split_template/2`, which Volt doesn't call. Projects that depend on vize themselves need 0.17 alongside this release. + ### Added - Changes in `reload_dirs` reload only the pages whose HTML changed. The watcher broadcasts a `document` update for them instead of a `full` one. Pages carry an entity tag of their rendered HTML; on a change the dev client revalidates with `If-None-Match` and reloads unless the server answers `304 Not Modified`. Editing one post no longer reloads every open tab, and a save that does not change a page's output reloads nothing. This covers every successful HTML response that passes through `Volt.DevServer`, including pages that already load the dev client, so site generators built on Volt need no change. `Volt.HMR.document_update/2` sends the same request from other packages, and `Volt.HMR.Document` describes the contract for servers that send HTML some other way. diff --git a/mix.exs b/mix.exs index 2f840a9..fe8375d 100644 --- a/mix.exs +++ b/mix.exs @@ -39,7 +39,7 @@ defmodule Volt.MixProject do {:reach, "~> 2.6.1", only: [:dev, :test], runtime: false}, {:glob_ex, "~> 0.1.12"}, {:oxc, "~> 0.18.1"}, - {:vize, "~> 0.16.0"}, + {:vize, "~> 0.17.0"}, {:oxide_ex, "~> 0.2.2"}, {:quickbeam, "~> 0.11.2"}, {:dotenvy, "~> 1.1"}, diff --git a/mix.lock b/mix.lock index aace6a8..4f1eb4d 100644 --- a/mix.lock +++ b/mix.lock @@ -64,7 +64,7 @@ "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, "varint": {:hex, :varint, "1.6.0", "7bced828599b2eb84491a9f067f8a5a67fc35a946d3b7582278083c7fd434b00", [:mix], [], "hexpm", "2b4f4a20650aeebe993a70b03bb99571bcbddc27c361322c28b586d8a772ce4e"}, - "vize": {:hex, :vize, "0.16.0", "6003d4a9111aa9007a47530edee137d8410b844a586a9d67077947e7fcb5096d", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "cf29ccd05c25d9c7c6f1168b82892fc204c432a5933711fb33f4511072b96e66"}, + "vize": {:hex, :vize, "0.17.0", "a553a9be0137e30e6d458cc2aec82cebd97d6f4814baeb67f92e9359f9ff69b7", [:mix], [{:rustler, "~> 0.36", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "da7bbb5c29d56fa3e1e367298500f80c53bc81b902fc53316f644e532eddb05a"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, "zig_get": {:hex, :zig_get, "0.15.2", "a6ccaa894213839ba95615bf9be2b2c9268e37ab9547a2344830202cfd6d7cc0", [:mix], [], "hexpm", "e6b0028f2d5a8da791ff8037deff5b492784017d8d241e598377a24bd765f56f"}, From 93036aaf8fa3b4635fbecda79436120d55d42847 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 20:44:41 +0400 Subject: [PATCH 108/128] Compare page renders in each tab's websocket process A page found out what changed by requesting itself again and diffing the answer against its live DOM, which scripts have modified since. So a change to an owned element's attributes, such as an island's props, could only be handled by reloading. Each open page already has a websocket process that lives as long as the page. With the new :document option, a function that renders a page's HTML, and :morph, that process keeps the HTML the server last rendered for its page. On a document update it renders the page again and compares the two server renders, then sends the page only what applies to it: nothing, a reload, or the new HTML with the owned elements whose attributes the server changed. For those elements the client sets the attributes and dispatches a cancelable volt:element-update event; the page reloads only if nothing handles it. Pages that were served other HTML than the server can reproduce fall back to revalidating themselves. --- CHANGELOG.md | 7 ++ guides/features/hmr.md | 25 +++++++ lib/volt/dev_server.ex | 10 ++- lib/volt/dev_server/client_tag.ex | 12 +++- lib/volt/dev_server/config.ex | 1 + lib/volt/hmr/document.ex | 73 +++++++++++++++++++++ lib/volt/hmr/message.ex | 3 +- lib/volt/hmr/socket.ex | 79 ++++++++++++++++++++++- priv/ts/client/document.ts | 80 +++++++++++++++++++---- priv/ts/client/hmr.ts | 14 +++- test/volt/hmr/document_test.exs | 70 ++++++++++++++++++++ test/volt/hmr/socket_test.exs | 104 ++++++++++++++++++++++++++++++ 12 files changed, 459 insertions(+), 19 deletions(-) create mode 100644 test/volt/hmr/document_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index c849de3..17153a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Added + +- `plug Volt.DevServer, document: {module, function, args}` lets the dev server render pages itself. With `:morph`, each open page's websocket process keeps the HTML the server last rendered for it, renders the page again when a template or content file changes, and compares the two renders. A page is then sent only what applies to it: nothing, a reload, or the new HTML to patch. Pages no longer request themselves to find out. +- When the server changed the attributes of an element matched by `morph: [preserve: selector]`, such as the props of a mounted component, the client sets them and dispatches a cancelable `volt:element-update` event on the element instead of reloading. The page reloads only if nothing handles the event. + ## 0.20.0 - 2026-10-03 ### Breaking changes diff --git a/guides/features/hmr.md b/guides/features/hmr.md index c715b50..0778ff9 100644 --- a/guides/features/hmr.md +++ b/guides/features/hmr.md @@ -127,6 +127,31 @@ After a patch the client dispatches `volt:document-updated` on `document`, for c document.addEventListener("volt:document-updated", () => enhance(document)) ``` +### Letting the server compare renders + +By default a page finds out what changed by requesting itself again. A server that can render a page outside a request can hand that to Volt instead: + +```elixir +plug Volt.DevServer, document: {MySite, :render_document, []} +``` + +The function is called with the page's path, followed by the listed arguments, and returns `{:ok, html}`, the HTML a request for that path is answered with, or `:error`. + +Each open page has its own websocket process, which lives as long as the page does. With `:document` and `:morph` set, that process keeps the HTML the server last rendered for its page. When a template or content file changes it renders the page again and compares the two renders, so each page is sent only what applies to it: + +- nothing, when its HTML is the same; +- a reload, when scripts or stylesheets differ or preserved elements were added or removed; +- otherwise the new HTML to patch, with the preserved elements whose attributes the server changed. + +Because both sides of the comparison are server renders, it sees what the server changed and nothing that scripts did to the page since. For each preserved element it names, the client sets the new attributes and dispatches a cancelable `volt:element-update` event on it. The element's owner re-renders it and calls `preventDefault()`; if nothing handles the event, the page reloads. + +```javascript +island.addEventListener("volt:element-update", (event) => { + event.preventDefault() + rerender(JSON.parse(island.dataset.props)) +}) +``` + Only the contents of `` and the title are patched. Attributes on `` and ``, and the rest of ``, stay as they were until the next reload. ## Ignoring watcher paths diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index d3744b4..f7ae1cf 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -19,6 +19,11 @@ defmodule Volt.DevServer do * `:target` — JS downlevel target (e.g. `:es2020`) * `:import_source` — JSX import source (e.g. `"vue"`) * `:vapor` — use Vue Vapor mode (default: `false`) + * `:document` — a function, or `{module, function, args}`, called with a + page's path, followed by `args`, to render the HTML a request for it is + answered with. With + it and the server's `:morph` setting, Volt compares renders for each open + page itself; see the HMR guide. * `:watch` — start the supervised file watcher on the first request (default: `true`) ## Example @@ -124,6 +129,7 @@ defmodule Volt.DevServer do Volt.Env.define(mode: "development", root: File.cwd!(), env_prefix: config.env_prefix), hmr_timeout: server_config.hmr_timeout, morph: server_config.morph, + document: Keyword.get(opts, :document), stylesheet_url: tailwind_root && tailwind_root.dev_url, stylesheet_source: tailwind_root && tailwind_root.css, session_supervisor: Keyword.get(opts, :session_supervisor), @@ -281,7 +287,9 @@ defmodule Volt.DevServer do defp do_call(%Conn{request_path: "/@volt/ws"} = conn, config) do conn - |> WebSockAdapter.upgrade(Volt.HMR.Socket, [session: config.session], + |> WebSockAdapter.upgrade( + Volt.HMR.Socket, + [session: config.session, document: config.document, morph: config.morph], timeout: config.hmr_timeout ) |> Conn.halt() diff --git a/lib/volt/dev_server/client_tag.ex b/lib/volt/dev_server/client_tag.ex index 8c9aa42..0cfc0fb 100644 --- a/lib/volt/dev_server/client_tag.ex +++ b/lib/volt/dev_server/client_tag.ex @@ -34,14 +34,22 @@ defmodule Volt.DevServer.ClientTag do if Document.fresh?(conn, etag) do %{conn | status: 304, resp_body: ""} else - attributes = attribute(Document.attribute(), etag) <> morph_attribute(morph) conn = Conn.put_resp_header(conn, "etag", etag) - %{conn | resp_body: put_client(html, attributes)} + %{conn | resp_body: tag(html, etag, morph)} end end defp inject_client(conn, html, _morph), do: %{conn | resp_body: put_client(html, "")} + @doc """ + Add the dev client to a successful page, with the page's entity tag and the + server's `:morph` setting, as a response sent through the dev server has them. + """ + @spec tag(String.t(), String.t(), boolean() | keyword()) :: String.t() + def tag(html, etag, morph) do + put_client(html, attribute(Document.attribute(), etag) <> morph_attribute(morph)) + end + # A page may already load the client, as pages rendered by a site generator # do. Its tag gets the attributes; otherwise the client is added to the head. defp put_client(html, attributes) do diff --git a/lib/volt/dev_server/config.ex b/lib/volt/dev_server/config.ex index 9fbcfba..50cc62b 100644 --- a/lib/volt/dev_server/config.ex +++ b/lib/volt/dev_server/config.ex @@ -16,6 +16,7 @@ defmodule Volt.DevServer.Config do define: %{}, hmr_timeout: 60_000, morph: false, + document: nil, session: :default, tables: nil, stylesheet_url: nil, diff --git a/lib/volt/hmr/document.ex b/lib/volt/hmr/document.ex index d3a9792..478b874 100644 --- a/lib/volt/hmr/document.ex +++ b/lib/volt/hmr/document.ex @@ -49,4 +49,77 @@ defmodule Volt.HMR.Document do @doc "Return whether the request already holds the HTML that `etag` identifies." @spec fresh?(Plug.Conn.t(), String.t()) :: boolean() def fresh?(conn, etag), do: etag in Plug.Conn.get_req_header(conn, "if-none-match") + + @typedoc """ + A preserved element whose server-rendered attributes changed: its position + among the elements matching the preserve selector, and its new attributes. + """ + @type owned_change :: %{index: non_neg_integer(), attributes: %{String.t() => String.t()}} + + @doc """ + Decide how a page gets from one server render to the next. + + Both arguments are HTML the server rendered, so the comparison sees only what + the server changed, never what scripts did to the page since. + + Returns `:reload` when patching cannot apply the change: the scripts the page + runs or its stylesheets differ, or elements matching `preserve` were added or + removed. Otherwise returns `{:patch, owned}`, where `owned` lists the + preserved elements whose attributes changed. Their owner, such as a mounted + component, is told and re-renders; the rest of the page is patched. + """ + @spec changes(String.t(), String.t(), String.t() | nil) :: :reload | {:patch, [owned_change()]} + def changes(previous, next, preserve) do + previous = Floki.parse_document!(previous) + next = Floki.parse_document!(next) + + cond do + scripts(previous) != scripts(next) -> :reload + stylesheets(previous) != stylesheets(next) -> :reload + true -> owned_changes(preserved(previous, preserve), preserved(next, preserve)) + end + end + + # Scripts the browser runs. Data blocks such as JSON are content and are patched. + defp scripts(document) do + for {"script", attributes, children} <- Floki.find(document, "script"), + type = attribute(attributes, "type"), + type in ["", "module", "text/javascript"], + do: {type, attribute(attributes, "src"), Floki.raw_html(children)} + end + + defp stylesheets(document) do + links = + for {"link", attributes, _children} <- Floki.find(document, "link[rel=stylesheet]"), + do: attribute(attributes, "href") + + styles = for {"style", _attributes, children} <- Floki.find(document, "style"), do: children + {links, styles} + end + + defp preserved(_document, preserve) when preserve in [nil, ""], do: [] + + defp preserved(document, preserve) do + for {_tag, attributes, _children} <- Floki.find(document, preserve), do: Map.new(attributes) + end + + defp owned_changes(previous, next) do + if length(previous) == length(next) do + owned = + for {{before, now}, index} <- Enum.with_index(Enum.zip(previous, next)), + before != now, + do: %{index: index, attributes: now} + + {:patch, owned} + else + :reload + end + end + + defp attribute(attributes, name) do + case List.keyfind(attributes, name, 0) do + {^name, value} -> value + nil -> "" + end + end end diff --git a/lib/volt/hmr/message.ex b/lib/volt/hmr/message.ex index d46557d..4ea0ecf 100644 --- a/lib/volt/hmr/message.ex +++ b/lib/volt/hmr/message.ex @@ -10,6 +10,7 @@ defmodule Volt.HMR.Message do * `update` — an HMR update payload (`path`, `changes`, optional `boundary`, `timestamp`) * `error` — the current build errors (`errors`, as `t:Volt.Dev.Error.entry/0` maps); an empty list hides the overlay + * `page` — sent by the browser client once connected: the `path` it shows and the `etag` of its HTML * `ping` — heartbeat sent by the browser client * `pong` — heartbeat reply from the server @@ -18,7 +19,7 @@ defmodule Volt.HMR.Message do use JSONCodec @type t :: %__MODULE__{ - type: :update | :error | :ping | :pong, + type: :update | :error | :page | :ping | :pong, payload: term() | nil } diff --git a/lib/volt/hmr/socket.ex b/lib/volt/hmr/socket.ex index dda6748..0370090 100644 --- a/lib/volt/hmr/socket.ex +++ b/lib/volt/hmr/socket.ex @@ -8,6 +8,15 @@ defmodule Volt.HMR.Socket do @behaviour WebSock require Logger + alias Volt.HMR.Document + + # One socket process lives as long as one open page. When the server can + # render documents and pages are patched in place, the process keeps the HTML + # the server last rendered for its page. On a document update it renders the + # page again and compares the two renders, so each page hears only what + # applies to it: nothing, a reload, or a patch. + defstruct session: :default, document: nil, morph: false, path: nil, html: nil + @impl true def init(nil), do: init([]) @@ -15,12 +24,27 @@ defmodule Volt.HMR.Socket do session = Keyword.get(args, :session, :default) Registry.register(Volt.HMR.Registry, Volt.HMR.Channel.key(session), nil) + morph = Keyword.get(args, :morph, false) + + state = %__MODULE__{ + session: session, + document: document(Keyword.get(args, :document), morph), + morph: morph + } + case Volt.HMR.Errors.list(session) do - [] -> {:ok, %{}} - errors -> push(%Volt.HMR.Message{type: :error, payload: %{errors: errors}}, %{}) + [] -> {:ok, state} + errors -> push(%Volt.HMR.Message{type: :error, payload: %{errors: errors}}, state) end end + # Without patching, a changed page reloads and fetches its own HTML. + defp document(_document, false), do: nil + defp document(document, _morph), do: document + + defp preserve(morph) when is_list(morph), do: Keyword.get(morph, :preserve) + defp preserve(_morph), do: nil + # Heartbeat: the browser client sends a `{"type":"ping"}` JSON message # every few seconds to keep Bandit's websocket `read_timeout` from closing # an otherwise idle connection. We reply with `{"type":"pong"}` so the @@ -32,6 +56,9 @@ defmodule Volt.HMR.Socket do {:ok, %Volt.HMR.Message{type: :ping}} -> push(%Volt.HMR.Message{type: :pong}, state) + {:ok, %Volt.HMR.Message{type: :page, payload: payload}} -> + {:ok, open_page(state, payload)} + {:ok, %Volt.HMR.Message{type: type} = message} -> Logger.debug("[Volt.HMR] Received: #{inspect(type)} #{inspect(message.payload)}") {:ok, state} @@ -42,7 +69,38 @@ defmodule Volt.HMR.Socket do end end + # The page says where it is and which HTML it was served. The render kept for + # it must be that HTML; if the sources changed in between, the page falls back + # to revalidating itself. + defp open_page(%__MODULE__{document: document} = state, %{"path" => path, "etag" => etag}) + when not is_nil(document) and is_binary(path) do + case render(document, path) do + {:ok, html} -> %{state | path: path, html: if(Document.etag(html) == etag, do: html)} + _error -> %{state | path: path, html: nil} + end + end + + defp open_page(state, _payload), do: state + @impl true + def handle_info( + {:volt_hmr, :update, %{changes: ["document"]} = payload}, + %__MODULE__{html: html} = state + ) + when is_binary(html) do + case render(state.document, state.path) do + {:ok, ^html} -> + {:ok, state} + + {:ok, next} -> + update_document(Document.changes(html, next, preserve(state.morph)), next, payload, state) + + _error -> + # The page revalidates itself and shows what the server answers. + push(%Volt.HMR.Message{type: :update, payload: payload}, %{state | html: nil}) + end + end + def handle_info({:volt_hmr, type, payload}, state) do push(%Volt.HMR.Message{type: type, payload: payload}, state) end @@ -51,6 +109,23 @@ defmodule Volt.HMR.Socket do {:ok, state} end + defp update_document(:reload, _next, payload, state) do + payload = %{payload | changes: ["full"]} + push(%Volt.HMR.Message{type: :update, payload: payload}, %{state | html: nil}) + end + + defp update_document({:patch, owned}, next, payload, state) do + etag = Document.etag(next) + + # The page gets the HTML a request for it would be answered with. + tagged = Volt.DevServer.ClientTag.tag(next, etag, state.morph) + payload = Map.merge(payload, %{html: tagged, etag: etag, owned: owned}) + push(%Volt.HMR.Message{type: :update, payload: payload}, %{state | html: next}) + end + + defp render({module, function, args}, path), do: apply(module, function, [path | args]) + defp render(document, path) when is_function(document, 1), do: document.(path) + defp push(%Volt.HMR.Message{} = message, state) do case encode(message) do {:ok, frame} -> {:push, frame, state} diff --git a/priv/ts/client/document.ts b/priv/ts/client/document.ts index fbdd720..a6419e0 100644 --- a/priv/ts/client/document.ts +++ b/priv/ts/client/document.ts @@ -4,6 +4,61 @@ import { pageReload } from './reload' const ETAG = 'data-volt-etag' const MORPH = 'data-volt-morph' +export type OwnedChange = { index: number; attributes: Record } + +/** A page render pushed by the server, which already compared it with the previous one. */ +export type PushedDocument = { html: string; etag: string; owned: OwnedChange[] } + +/** Where this page is and which HTML it was served, for the server to keep track of it. */ +export function pageIdentity() { + const etag = document.querySelector(`script[${ETAG}]`)?.getAttribute(ETAG) + return etag ? { path: location.pathname + location.search, etag } : null +} + +/** + * Apply a render the server pushed. The server compared it with the HTML it + * last rendered for this page, so it knows which owned elements it changed. + */ +export function applyDocument({ html, etag, owned }: PushedDocument) { + const preserve = document.querySelector(`script[${ETAG}]`)?.getAttribute(MORPH) + if (preserve === null || preserve === undefined) return pageReload() + + const next = new DOMParser().parseFromString(html, 'text/html') + const changed = new Set(owned.map((change) => change.index)) + if (!morphDocument(next, preserve, changed)) return pageReload() + if (!updateOwned(preserve, owned)) return pageReload() + + finishUpdate(etag) +} + +// The owner of an element re-renders it from its new attributes and says so by +// cancelling the event. Without an owner listening, only a reload applies them. +function updateOwned(preserve: string, owned: OwnedChange[]) { + if (owned.length === 0) return true + const elements = [...document.querySelectorAll(preserve)] + + return owned.every(({ index, attributes }) => { + const element = elements[index] + if (!element) return false + + for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value) + + const event = new CustomEvent('volt:element-update', { + bubbles: true, + cancelable: true, + detail: { attributes } + }) + + return !element.dispatchEvent(event) + }) +} + +function finishUpdate(etag: string) { + document.querySelector(`script[${ETAG}]`)?.setAttribute(ETAG, etag) + document.dispatchEvent(new CustomEvent('volt:document-updated')) + console.log('[Volt] Document updated') +} + let revalidation: Promise | undefined // The server-rendered HTML may have changed. A page that carries the entity tag @@ -43,9 +98,7 @@ async function updateDocument() { const next = new DOMParser().parseFromString(await response.text(), 'text/html') if (!morphDocument(next, preserve)) return pageReload() - document.querySelector(`script[${ETAG}]`)?.setAttribute(ETAG, nextEtag) - document.dispatchEvent(new CustomEvent('volt:document-updated')) - console.log('[Volt] Document updated') + finishUpdate(nextEtag) } /** @@ -54,14 +107,15 @@ async function updateDocument() { * Returns false, changing nothing, when the difference cannot be applied by * patching: scripts do not run again, stylesheets are not reloaded, and * elements matching `preserve` are owned by client code such as a mounted - * component. + * component. `changed` holds the positions of owned elements the server changed. */ -export function morphDocument(next: Document, preserve: string) { +export function morphDocument(next: Document, preserve: string, changed = new Set()) { if (!sameItems(scripts(document), scripts(next))) return false if (!sameItems(stylesheets(document), stylesheets(next))) return false const preserved = preserve ? [...document.querySelectorAll(preserve)] : [] - if (!samePreserved(preserved, preserve ? [...next.querySelectorAll(preserve)] : [])) return false + const nextPreserved = preserve ? [...next.querySelectorAll(preserve)] : [] + if (!samePreserved(preserved, nextPreserved, changed)) return false const owned = (node: Node) => preserve !== '' && node instanceof Element && node.matches(preserve) @@ -114,13 +168,17 @@ function stylesheetUrl(href: string) { // Client code may add attributes to an element it owns, such as a mount marker, // so only the attributes the server rendered are compared. -function samePreserved(current: Element[], next: Element[]) { +// The elements in `changed` are ones the server knows it changed; their owners +// are told separately. +function samePreserved(current: Element[], next: Element[], changed: Set) { return ( current.length === next.length && - next.every((element, index) => - [...element.attributes].every( - (attribute) => current[index]?.getAttribute(attribute.name) === attribute.value - ) + next.every( + (element, index) => + changed.has(index) || + [...element.attributes].every( + (attribute) => current[index]?.getAttribute(attribute.name) === attribute.value + ) ) ) } diff --git a/priv/ts/client/hmr.ts b/priv/ts/client/hmr.ts index 54c7bbf..83eb178 100644 --- a/priv/ts/client/hmr.ts +++ b/priv/ts/client/hmr.ts @@ -6,7 +6,7 @@ import { type HotCallback } from './hot' import { clearErrorOverlay, renderErrorOverlay, type VoltError } from './overlay' -import { revalidateDocument } from './document' +import { applyDocument, pageIdentity, revalidateDocument, type PushedDocument } from './document' import { pageReload } from './reload' import { removeStyle, updateStyle, updateStyles } from './styles' @@ -42,6 +42,9 @@ function connect() { connected = true console.log('[Volt] HMR connected') + const identity = pageIdentity() + if (identity) ws?.send(JSON.stringify({ type: 'page', payload: identity })) + if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = undefined @@ -126,7 +129,14 @@ async function handleUpdate(payload: { } if (changes.length === 1 && changes[0] === 'document') { - await revalidateDocument() + const pushed = payload as Partial + + if (typeof pushed.html === 'string' && typeof pushed.etag === 'string') { + applyDocument({ html: pushed.html, etag: pushed.etag, owned: pushed.owned ?? [] }) + } else { + await revalidateDocument() + } + return } diff --git a/test/volt/hmr/document_test.exs b/test/volt/hmr/document_test.exs new file mode 100644 index 0000000..61f0154 --- /dev/null +++ b/test/volt/hmr/document_test.exs @@ -0,0 +1,70 @@ +defmodule Volt.HMR.DocumentTest do + use ExUnit.Case, async: true + + alias Volt.HMR.Document + + defp page(body, head \\ "") do + "t#{head}#{body}" + end + + describe "changes/3" do + test "patches changes to content" do + assert Document.changes(page("

one

"), page("

two

"), nil) == + {:patch, []} + end + + test "patches data blocks, which the browser does not run" do + before = page(~s()) + now = page(~s()) + + assert Document.changes(before, now, nil) == {:patch, []} + end + + test "reloads when the scripts the page runs differ" do + before = page(~s()) + + assert Document.changes(before, page(~s()), nil) == + :reload + + assert Document.changes(page(""), page(""), nil) == + :reload + + assert Document.changes(before, page(""), nil) == :reload + end + + test "reloads when the stylesheets differ" do + link = ~s() + + assert Document.changes( + page("", link), + page("", ~s()), + nil + ) == + :reload + + assert Document.changes(page("", ""), page("", ""), nil) == + :reload + end + + test "names the owned elements whose attributes the server changed" do + before = + page( + ~s(
) + ) + + now = + page( + ~s(
) + ) + + assert Document.changes(before, now, "[data-island]") == + {:patch, [%{index: 1, attributes: %{"data-island" => "b", "data-props" => "2"}}]} + end + + test "reloads when owned elements are added or removed" do + before = page(~s(
)) + + assert Document.changes(before, page(""), "[data-island]") == :reload + end + end +end diff --git a/test/volt/hmr/socket_test.exs b/test/volt/hmr/socket_test.exs index 6b6c665..6789572 100644 --- a/test/volt/hmr/socket_test.exs +++ b/test/volt/hmr/socket_test.exs @@ -23,6 +23,110 @@ defmodule Volt.HMR.SocketTest do end end + describe "a page the server keeps a render of" do + setup do + {:ok, renders} = Agent.start_link(fn -> page("first", ~s({"n":1})) end) + document = fn "/post" -> {:ok, Agent.get(renders, & &1)} end + + {:ok, state} = + Volt.HMR.Socket.init(document: document, morph: [preserve: "[data-island]"]) + + html = Agent.get(renders, & &1) + opened = open_page(state, "/post", Volt.HMR.Document.etag(html)) + %{renders: renders, state: state, opened: opened} + end + + test "hears nothing when its HTML did not change", %{opened: opened} do + assert {:ok, ^opened} = Volt.HMR.Socket.handle_info(document_update(), opened) + end + + test "is sent the new HTML when it can be patched", %{renders: renders, opened: opened} do + Agent.update(renders, fn _ -> page("second", ~s({"n":1})) end) + + assert {:push, {:text, json}, state} = + Volt.HMR.Socket.handle_info(document_update(), opened) + + assert %{"changes" => ["document"], "html" => html, "etag" => etag, "owned" => []} = + Jason.decode!(json)["payload"] + + assert html =~ "second" + + # The pushed page keeps what marks it for the next update. + assert html =~ ~s(data-volt-morph="[data-island]") + assert html =~ "data-volt-etag" + assert etag == Volt.HMR.Document.etag(Agent.get(renders, & &1)) + + # The new render is what the next change is compared with. + assert {:ok, ^state} = Volt.HMR.Socket.handle_info(document_update(), state) + end + + test "is told which owned elements the server changed", %{renders: renders, opened: opened} do + Agent.update(renders, fn _ -> page("first", ~s({"n":2})) end) + + assert {:push, {:text, json}, _state} = + Volt.HMR.Socket.handle_info(document_update(), opened) + + assert [%{"index" => 0, "attributes" => %{"data-props" => ~s({"n":2})}}] = + Jason.decode!(json)["payload"]["owned"] + end + + test "reloads when the change cannot be patched", %{renders: renders, opened: opened} do + Agent.update(renders, fn html -> String.replace(html, "run()", "run(2)") end) + + assert {:push, {:text, json}, _state} = + Volt.HMR.Socket.handle_info(document_update(), opened) + + payload = Jason.decode!(json)["payload"] + assert payload["changes"] == ["full"] + refute Map.has_key?(payload, "html") + end + + test "revalidates itself when it was served different HTML", %{state: state} do + stale = open_page(state, "/post", ~s("another")) + + assert {:push, {:text, json}, _state} = + Volt.HMR.Socket.handle_info(document_update(), stale) + + payload = Jason.decode!(json)["payload"] + assert payload["changes"] == ["document"] + refute Map.has_key?(payload, "html") + end + + test "is not kept track of unless pages are patched in place", %{renders: renders} do + document = fn "/post" -> {:ok, Agent.get(renders, & &1)} end + {:ok, state} = Volt.HMR.Socket.init(document: document) + html = Agent.get(renders, & &1) + opened = open_page(state, "/post", Volt.HMR.Document.etag(html)) + + Agent.update(renders, fn _ -> page("second", "{}") end) + + assert {:push, {:text, json}, _state} = + Volt.HMR.Socket.handle_info(document_update(), opened) + + refute Map.has_key?(Jason.decode!(json)["payload"], "html") + end + + defp page(text, props) do + """ + Post +

#{text}

+
+ + + + """ + end + + defp open_page(state, path, etag) do + frame = Jason.encode!(%{type: "page", payload: %{path: path, etag: etag}}) + {:ok, state} = Volt.HMR.Socket.handle_in({frame, opcode: :text}, state) + state + end + + defp document_update, + do: {:volt_hmr, :update, %{path: "content/post.md", changes: ["document"]}} + end + describe "handle_info/2" do test "broadcasts HMR messages as JSON" do {:ok, state} = Volt.HMR.Socket.init(nil) From 0f1d4d47190fa5eefa4dc35d5c0ccecdd8737bf0 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 21:04:37 +0400 Subject: [PATCH 109/128] Push root and head changes, and start from the served HTML The comparison of two server renders now also names the attributes the server changed on and , and the metadata and links it changed in . The client applies only those, so an attribute a script set, such as a theme, stays; a blind copy from the new document would remove it. A page's websocket process rendered the page when it connected, to have something to compare with. The dev server now keeps the HTML of each page it serves until the page connects, so opening a page costs no extra render. At most 64 pages per session are kept, for pages that never connect. A browser test covers the whole push flow: no request from the page, an owned element re-rendered by its owner, and root and head changes applied. --- .reach.exs | 1 + CHANGELOG.md | 1 + guides/features/hmr.md | 8 +- lib/volt/application.ex | 1 + lib/volt/dev/cleanup.ex | 1 + lib/volt/dev_server.ex | 11 ++- lib/volt/dev_server/client_tag.ex | 22 ++++-- lib/volt/hmr/document.ex | 80 +++++++++++++++++--- lib/volt/hmr/documents.ex | 31 ++++++++ lib/volt/hmr/socket.ex | 27 ++++--- priv/ts/client/document.ts | 44 ++++++++++- priv/ts/client/hmr.ts | 8 +- test/volt/hmr/document_test.exs | 38 +++++++++- test/volt/hmr/socket_test.exs | 20 +++++ test/volt/integration/hmr_test.exs | 113 ++++++++++++++++++++++++++++- 15 files changed, 366 insertions(+), 40 deletions(-) create mode 100644 lib/volt/hmr/documents.ex diff --git a/.reach.exs b/.reach.exs index 95d1719..102124f 100644 --- a/.reach.exs +++ b/.reach.exs @@ -152,6 +152,7 @@ infrastructure = [ "Volt.Dev.ConsoleForwarder.Payload", "Volt.ETS", "Volt.HMR.Boundary", + "Volt.HMR.Documents", "Volt.HMR.Errors", "Volt.HMR.GlobGraph", "Volt.HMR.ImportGraph", diff --git a/CHANGELOG.md b/CHANGELOG.md index 17153a0..2fa9fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - `plug Volt.DevServer, document: {module, function, args}` lets the dev server render pages itself. With `:morph`, each open page's websocket process keeps the HTML the server last rendered for it, renders the page again when a template or content file changes, and compares the two renders. A page is then sent only what applies to it: nothing, a reload, or the new HTML to patch. Pages no longer request themselves to find out. +- With `:document`, the attributes the server changed on `` and `` and the metadata and links it changed in `` are applied too. Attributes that scripts set on those elements, such as a theme, are left alone, because the comparison only sees what the server rendered. - When the server changed the attributes of an element matched by `morph: [preserve: selector]`, such as the props of a mounted component, the client sets them and dispatches a cancelable `volt:element-update` event on the element instead of reloading. The page reloads only if nothing handles the event. ## 0.20.0 - 2026-10-03 diff --git a/guides/features/hmr.md b/guides/features/hmr.md index 0778ff9..e1a43cc 100644 --- a/guides/features/hmr.md +++ b/guides/features/hmr.md @@ -141,9 +141,11 @@ Each open page has its own websocket process, which lives as long as the page do - nothing, when its HTML is the same; - a reload, when scripts or stylesheets differ or preserved elements were added or removed; -- otherwise the new HTML to patch, with the preserved elements whose attributes the server changed. +- otherwise the new HTML to patch, with what the server changed outside the body's contents: attributes of `` and ``, metadata and links in ``, and the attributes of preserved elements. -Because both sides of the comparison are server renders, it sees what the server changed and nothing that scripts did to the page since. For each preserved element it names, the client sets the new attributes and dispatches a cancelable `volt:element-update` event on it. The element's owner re-renders it and calls `preventDefault()`; if nothing handles the event, the page reloads. +Because both sides of the comparison are server renders, it sees what the server changed and nothing that scripts did to the page since. An attribute a script set on ``, such as a theme, is in neither render and stays. The page starts from the HTML it was served, which the dev server keeps until the page connects, so opening a page costs no extra render. + +For each preserved element it names, the client sets the new attributes and dispatches a cancelable `volt:element-update` event on it. The element's owner re-renders it and calls `preventDefault()`; if nothing handles the event, the page reloads. ```javascript island.addEventListener("volt:element-update", (event) => { @@ -152,7 +154,7 @@ island.addEventListener("volt:element-update", (event) => { }) ``` -Only the contents of `` and the title are patched. Attributes on `` and ``, and the rest of ``, stay as they were until the next reload. +Without `:document`, only the contents of `` and the title are patched. Attributes on `` and ``, and the rest of ``, stay as they were until the next reload. ## Ignoring watcher paths diff --git a/lib/volt/application.ex b/lib/volt/application.ex index b1c20d9..8a8ac3b 100644 --- a/lib/volt/application.ex +++ b/lib/volt/application.ex @@ -11,6 +11,7 @@ defmodule Volt.Application do Volt.HMR.StyleGraph.create_table() Volt.HMR.ModuleGraph.create_table() Volt.HMR.Errors.create_table() + Volt.HMR.Documents.create_table() # Watchers release their Tailwind contexts when they terminate, so the # Tailwind processes start before them and stop after them. diff --git a/lib/volt/dev/cleanup.ex b/lib/volt/dev/cleanup.ex index ac813c3..7fdd716 100644 --- a/lib/volt/dev/cleanup.ex +++ b/lib/volt/dev/cleanup.ex @@ -10,6 +10,7 @@ defmodule Volt.Dev.Cleanup do Volt.HMR.StyleGraph.clear_session(session) Volt.HMR.ModuleGraph.clear_session(session) Volt.HMR.Errors.clear_session(session) + Volt.HMR.Documents.clear_session(session) :ok end end diff --git a/lib/volt/dev_server.ex b/lib/volt/dev_server.ex index f7ae1cf..6f813d4 100644 --- a/lib/volt/dev_server.ex +++ b/lib/volt/dev_server.ex @@ -358,11 +358,20 @@ defmodule Volt.DevServer do serve(conn, relative, config) :no_match -> - Volt.DevServer.ClientTag.register(conn, config.morph) + Volt.DevServer.ClientTag.register(conn, config.morph, + keep_for: document_session(config) + ) end end end + # Served HTML is kept only when a page's websocket process will compare renders. + defp document_session(%{document: document, morph: morph, session: session}) + when not is_nil(document) and morph != false, + do: session + + defp document_session(_config), do: nil + defp serve_virtual(conn, id, config) do case Volt.PluginRunner.load(config.plugins, id) do {:ok, source, content_type} -> diff --git a/lib/volt/dev_server/client_tag.ex b/lib/volt/dev_server/client_tag.ex index 0cfc0fb..2121faa 100644 --- a/lib/volt/dev_server/client_tag.ex +++ b/lib/volt/dev_server/client_tag.ex @@ -15,31 +15,37 @@ defmodule Volt.DevServer.ClientTag do Add the dev client to the HTML this connection sends. `morph` is the server's `:morph` setting: `false`, `true`, or - `[preserve: selector]`. + `[preserve: selector]`. With `keep_for: session`, the HTML of a successful + page is kept for the websocket process of the page to start from. """ - @spec register(Conn.t(), boolean() | keyword()) :: Conn.t() - def register(conn, morph \\ false), do: Conn.register_before_send(conn, &inject(&1, morph)) + @spec register(Conn.t(), boolean() | keyword(), keyword()) :: Conn.t() + def register(conn, morph \\ false, opts \\ []) do + Conn.register_before_send(conn, &inject(&1, morph, Keyword.get(opts, :keep_for))) + end - defp inject(%Conn{resp_body: body} = conn, morph) when not is_nil(body) do - if html?(conn), do: inject_client(conn, IO.iodata_to_binary(body), morph), else: conn + defp inject(%Conn{resp_body: body} = conn, morph, session) when not is_nil(body) do + if html?(conn), + do: inject_client(conn, IO.iodata_to_binary(body), morph, session), + else: conn end - defp inject(conn, _morph), do: conn + defp inject(conn, _morph, _session), do: conn # Successful pages carry an entity tag so the client can revalidate them # instead of reloading; see `Volt.HMR.Document`. - defp inject_client(%Conn{status: 200} = conn, html, morph) do + defp inject_client(%Conn{status: 200} = conn, html, morph, session) do etag = Document.etag(html) if Document.fresh?(conn, etag) do %{conn | status: 304, resp_body: ""} else + if session, do: Volt.HMR.Documents.put(session, etag, html) conn = Conn.put_resp_header(conn, "etag", etag) %{conn | resp_body: tag(html, etag, morph)} end end - defp inject_client(conn, html, _morph), do: %{conn | resp_body: put_client(html, "")} + defp inject_client(conn, html, _morph, _session), do: %{conn | resp_body: put_client(html, "")} @doc """ Add the dev client to a successful page, with the page's entity tag and the diff --git a/lib/volt/hmr/document.ex b/lib/volt/hmr/document.ex index 478b874..b0a0b92 100644 --- a/lib/volt/hmr/document.ex +++ b/lib/volt/hmr/document.ex @@ -56,30 +56,88 @@ defmodule Volt.HMR.Document do """ @type owned_change :: %{index: non_neg_integer(), attributes: %{String.t() => String.t()}} + @typedoc "Attributes the server set or changed on an element, and those it removed." + @type attribute_changes :: %{set: %{String.t() => String.t()}, remove: [String.t()]} + + @typedoc """ + What the server changed outside the body's contents, which are patched from + the new HTML itself: + + * `:owned` — preserved elements whose attributes changed + * `:root` — attribute changes on `"html"` and `"body"`, for those that have any + * `:head` — head elements, as HTML, that are gone and that are new. Scripts, + stylesheets and the title are not among them. + """ + @type patch :: %{ + owned: [owned_change()], + root: %{String.t() => attribute_changes()}, + head: %{remove: [String.t()], add: [String.t()]} + } + @doc """ Decide how a page gets from one server render to the next. Both arguments are HTML the server rendered, so the comparison sees only what - the server changed, never what scripts did to the page since. + the server changed, never what scripts did to the page since. An attribute a + script set on `` is in neither render and is left alone. Returns `:reload` when patching cannot apply the change: the scripts the page runs or its stylesheets differ, or elements matching `preserve` were added or - removed. Otherwise returns `{:patch, owned}`, where `owned` lists the - preserved elements whose attributes changed. Their owner, such as a mounted - component, is told and re-renders; the rest of the page is patched. + removed. Otherwise returns `{:patch, patch}`. """ - @spec changes(String.t(), String.t(), String.t() | nil) :: :reload | {:patch, [owned_change()]} + @spec changes(String.t(), String.t(), String.t() | nil) :: :reload | {:patch, patch()} def changes(previous, next, preserve) do previous = Floki.parse_document!(previous) next = Floki.parse_document!(next) - cond do - scripts(previous) != scripts(next) -> :reload - stylesheets(previous) != stylesheets(next) -> :reload - true -> owned_changes(preserved(previous, preserve), preserved(next, preserve)) + with true <- scripts(previous) == scripts(next), + true <- stylesheets(previous) == stylesheets(next), + {:ok, owned} <- owned_changes(preserved(previous, preserve), preserved(next, preserve)) do + {:patch, + %{ + owned: owned, + root: root_changes(previous, next), + head: head_changes(head_elements(previous), head_elements(next)) + }} + else + _cannot_patch -> :reload + end + end + + defp root_changes(previous, next) do + for tag <- ["html", "body"], + changes = attribute_changes(root_attributes(previous, tag), root_attributes(next, tag)), + changes != %{set: %{}, remove: []}, + into: %{}, + do: {tag, changes} + end + + defp root_attributes(document, tag) do + case Floki.find(document, tag) do + [{^tag, attributes, _children} | _] -> Map.new(attributes) + [] -> %{} end end + defp attribute_changes(previous, next) do + %{ + set: Map.reject(next, fn {name, value} -> Map.get(previous, name) == value end), + remove: Map.keys(previous) -- Map.keys(next) + } + end + + # Head elements that can be replaced without side effects: metadata and links + # other than stylesheets. + defp head_elements(document) do + for {"head", _attributes, children} <- Floki.find(document, "head"), + {tag, attributes, _children} = element <- children, + tag not in ["script", "style", "title"], + not (tag == "link" and attribute(attributes, "rel") == "stylesheet"), + do: Floki.raw_html(element) + end + + defp head_changes(previous, next), do: %{remove: previous -- next, add: next -- previous} + # Scripts the browser runs. Data blocks such as JSON are content and are patched. defp scripts(document) do for {"script", attributes, children} <- Floki.find(document, "script"), @@ -110,9 +168,9 @@ defmodule Volt.HMR.Document do before != now, do: %{index: index, attributes: now} - {:patch, owned} + {:ok, owned} else - :reload + :error end end diff --git a/lib/volt/hmr/documents.ex b/lib/volt/hmr/documents.ex new file mode 100644 index 0000000..d3a2bc5 --- /dev/null +++ b/lib/volt/hmr/documents.ex @@ -0,0 +1,31 @@ +defmodule Volt.HMR.Documents do + @moduledoc false + # The HTML recently served for pages, per session and entity tag, so the + # websocket process of a page that connects can start from the HTML the page + # was served without rendering it again. + + @table :volt_hmr_documents + + # Pages that never connect, such as requests from other tools, leave their + # HTML behind. A session keeps at most this many before starting over. + @limit 64 + + def create_table, do: Volt.ETS.create_named_set(@table) + + def put(session, etag, html) do + if count(session) >= @limit, do: clear_session(session) + Volt.ETS.put(@table, {{session, etag}, html}) + end + + @spec fetch(term(), String.t()) :: {:ok, String.t()} | :error + def fetch(session, etag) do + case :ets.lookup(@table, {session, etag}) do + [{_key, html}] -> {:ok, html} + [] -> :error + end + end + + def clear_session(session), do: Volt.ETS.clear_session(@table, session) + + defp count(session), do: :ets.select_count(@table, [{{{session, :_}, :_}, [], [true]}]) +end diff --git a/lib/volt/hmr/socket.ex b/lib/volt/hmr/socket.ex index 0370090..f84feee 100644 --- a/lib/volt/hmr/socket.ex +++ b/lib/volt/hmr/socket.ex @@ -69,15 +69,13 @@ defmodule Volt.HMR.Socket do end end - # The page says where it is and which HTML it was served. The render kept for - # it must be that HTML; if the sources changed in between, the page falls back + # The page says where it is and which HTML it was served. That HTML was kept + # when it was sent. If it is gone, the page is rendered again, and the render + # is used only if it is the HTML the page has; otherwise the page falls back # to revalidating itself. defp open_page(%__MODULE__{document: document} = state, %{"path" => path, "etag" => etag}) - when not is_nil(document) and is_binary(path) do - case render(document, path) do - {:ok, html} -> %{state | path: path, html: if(Document.etag(html) == etag, do: html)} - _error -> %{state | path: path, html: nil} - end + when not is_nil(document) and is_binary(path) and is_binary(etag) do + %{state | path: path, html: served_html(state, path, etag)} end defp open_page(state, _payload), do: state @@ -114,15 +112,26 @@ defmodule Volt.HMR.Socket do push(%Volt.HMR.Message{type: :update, payload: payload}, %{state | html: nil}) end - defp update_document({:patch, owned}, next, payload, state) do + defp update_document({:patch, patch}, next, payload, state) do etag = Document.etag(next) # The page gets the HTML a request for it would be answered with. tagged = Volt.DevServer.ClientTag.tag(next, etag, state.morph) - payload = Map.merge(payload, %{html: tagged, etag: etag, owned: owned}) + payload = payload |> Map.merge(patch) |> Map.merge(%{html: tagged, etag: etag}) push(%Volt.HMR.Message{type: :update, payload: payload}, %{state | html: next}) end + defp served_html(state, path, etag) do + with :error <- Volt.HMR.Documents.fetch(state.session, etag), + {:ok, html} <- render(state.document, path), + ^etag <- Document.etag(html) do + html + else + {:ok, html} -> html + _other -> nil + end + end + defp render({module, function, args}, path), do: apply(module, function, [path | args]) defp render(document, path) when is_function(document, 1), do: document.(path) diff --git a/priv/ts/client/document.ts b/priv/ts/client/document.ts index a6419e0..97e009d 100644 --- a/priv/ts/client/document.ts +++ b/priv/ts/client/document.ts @@ -6,8 +6,17 @@ const MORPH = 'data-volt-morph' export type OwnedChange = { index: number; attributes: Record } +export type AttributeChanges = { set: Record; remove: string[] } +export type HeadChanges = { remove: string[]; add: string[] } + /** A page render pushed by the server, which already compared it with the previous one. */ -export type PushedDocument = { html: string; etag: string; owned: OwnedChange[] } +export type PushedDocument = { + html: string + etag: string + owned: OwnedChange[] + root: Record + head: HeadChanges +} /** Where this page is and which HTML it was served, for the server to keep track of it. */ export function pageIdentity() { @@ -19,7 +28,7 @@ export function pageIdentity() { * Apply a render the server pushed. The server compared it with the HTML it * last rendered for this page, so it knows which owned elements it changed. */ -export function applyDocument({ html, etag, owned }: PushedDocument) { +export function applyDocument({ html, etag, owned, root, head }: PushedDocument) { const preserve = document.querySelector(`script[${ETAG}]`)?.getAttribute(MORPH) if (preserve === null || preserve === undefined) return pageReload() @@ -28,9 +37,40 @@ export function applyDocument({ html, etag, owned }: PushedDocument) { if (!morphDocument(next, preserve, changed)) return pageReload() if (!updateOwned(preserve, owned)) return pageReload() + updateRoot(root) + updateHead(head) finishUpdate(etag) } +// Only what the server changed is applied, so attributes that scripts set on +// these elements, such as a theme, stay. +function updateRoot(root: Record) { + for (const [tag, { set, remove }] of Object.entries(root)) { + const element = tag === 'html' ? document.documentElement : document.body + for (const name of remove) element.removeAttribute(name) + for (const [name, value] of Object.entries(set)) element.setAttribute(name, value) + } +} + +function updateHead({ remove, add }: HeadChanges) { + const live = [...document.head.children] + + for (const html of remove) { + const gone = parseHeadElement(html) + const index = live.findIndex((element) => gone !== null && element.isEqualNode(gone)) + if (index !== -1) live.splice(index, 1)[0]?.remove() + } + + for (const html of add) { + const element = parseHeadElement(html) + if (element) document.head.append(element) + } +} + +function parseHeadElement(html: string) { + return new DOMParser().parseFromString(`${html}`, 'text/html').head.firstElementChild +} + // The owner of an element re-renders it from its new attributes and says so by // cancelling the event. Without an owner listening, only a reload applies them. function updateOwned(preserve: string, owned: OwnedChange[]) { diff --git a/priv/ts/client/hmr.ts b/priv/ts/client/hmr.ts index 83eb178..48d9a9d 100644 --- a/priv/ts/client/hmr.ts +++ b/priv/ts/client/hmr.ts @@ -132,7 +132,13 @@ async function handleUpdate(payload: { const pushed = payload as Partial if (typeof pushed.html === 'string' && typeof pushed.etag === 'string') { - applyDocument({ html: pushed.html, etag: pushed.etag, owned: pushed.owned ?? [] }) + applyDocument({ + html: pushed.html, + etag: pushed.etag, + owned: pushed.owned ?? [], + root: pushed.root ?? {}, + head: pushed.head ?? { remove: [], add: [] } + }) } else { await revalidateDocument() } diff --git a/test/volt/hmr/document_test.exs b/test/volt/hmr/document_test.exs index 61f0154..5612a4a 100644 --- a/test/volt/hmr/document_test.exs +++ b/test/volt/hmr/document_test.exs @@ -10,14 +10,15 @@ defmodule Volt.HMR.DocumentTest do describe "changes/3" do test "patches changes to content" do assert Document.changes(page("

one

"), page("

two

"), nil) == - {:patch, []} + {:patch, %{owned: [], root: %{}, head: %{remove: [], add: []}}} end test "patches data blocks, which the browser does not run" do before = page(~s()) now = page(~s()) - assert Document.changes(before, now, nil) == {:patch, []} + assert Document.changes(before, now, nil) == + {:patch, %{owned: [], root: %{}, head: %{remove: [], add: []}}} end test "reloads when the scripts the page runs differ" do @@ -57,8 +58,37 @@ defmodule Volt.HMR.DocumentTest do ~s(
) ) - assert Document.changes(before, now, "[data-island]") == - {:patch, [%{index: 1, attributes: %{"data-island" => "b", "data-props" => "2"}}]} + assert {:patch, %{owned: owned}} = Document.changes(before, now, "[data-island]") + assert owned == [%{index: 1, attributes: %{"data-island" => "b", "data-props" => "2"}}] + end + + test "names the attributes the server changed on html and body" do + before = + ~s(

x

) + + now = + ~s(

x

) + + assert {:patch, %{root: root}} = Document.changes(before, now, nil) + + assert root == %{ + "html" => %{set: %{"lang" => "ru"}, remove: ["data-a"]}, + "body" => %{set: %{"data-route" => "/x"}, remove: []} + } + end + + test "names the head elements that are gone and that are new" do + before = + page("", ~s()) + + now = page("", ~s()) + + assert {:patch, %{head: head}} = Document.changes(before, now, nil) + + assert head == %{ + remove: [~s()], + add: [~s()] + } end test "reloads when owned elements are added or removed" do diff --git a/test/volt/hmr/socket_test.exs b/test/volt/hmr/socket_test.exs index 6789572..cffa620 100644 --- a/test/volt/hmr/socket_test.exs +++ b/test/volt/hmr/socket_test.exs @@ -92,6 +92,26 @@ defmodule Volt.HMR.SocketTest do refute Map.has_key?(payload, "html") end + test "starts from the HTML it was served without rendering again" do + {:ok, calls} = Agent.start_link(fn -> 0 end) + + document = fn "/post" -> + Agent.update(calls, &(&1 + 1)) + {:ok, page("first", "{}")} + end + + served = page("first", "{}") + etag = Volt.HMR.Document.etag(served) + Volt.HMR.Documents.put(:default, etag, served) + on_exit(fn -> Volt.HMR.Documents.clear_session(:default) end) + + {:ok, state} = Volt.HMR.Socket.init(document: document, morph: true) + opened = open_page(state, "/post", etag) + + assert opened.html == served + assert Agent.get(calls, & &1) == 0 + end + test "is not kept track of unless pages are patched in place", %{renders: renders} do document = fn "/post" -> {:ok, Agent.get(renders, & &1)} end {:ok, state} = Volt.HMR.Socket.init(document: document) diff --git a/test/volt/integration/hmr_test.exs b/test/volt/integration/hmr_test.exs index 901eba2..41fbb4a 100644 --- a/test/volt/integration/hmr_test.exs +++ b/test/volt/integration/hmr_test.exs @@ -16,8 +16,54 @@ defmodule Volt.Integration.TestPlug do def call(%Conn{request_path: "/morph/" <> name} = conn, opts), do: document(conn, name, opts[:root], opts[:morph_dev_server]) + # A page the server can render outside a request, so the page's websocket + # process compares renders and pushes what changed. + def call(%Conn{request_path: "/push/" <> _name = path} = conn, opts) do + {:ok, html} = render(path, opts[:root]) + + conn + |> Volt.DevServer.call(opts[:push_dev_server]) + |> Conn.put_resp_content_type("text/html") + |> Conn.send_resp(200, html) + end + + # Every page shares one websocket endpoint, served by the dev server that can + # render documents; it only knows the `/push/` pages. + def call(%Conn{request_path: "/@volt/ws"} = conn, opts), + do: Volt.DevServer.call(conn, opts[:push_dev_server]) + def call(conn, opts), do: asset(conn, opts) + def render("/push/" <> name, root) do + [text, props, lang, description] = + Path.join(root <> "-content", name <> ".md") |> File.read!() |> String.split("|") + + {:ok, + """ + + #{name} + + +
#{text}
+
+ + + """} + end + + def render(_path, _root), do: :error + defp document(conn, name, root, dev_server) do content = File.read!(Path.join(root <> "-content", name <> ".md")) @@ -148,7 +194,20 @@ defmodule Volt.Integration.HMRTest do dev_server_opts = Volt.DevServer.init(root: @fixture_dir, prefix: "/assets") morph_opts = Volt.DevServer.init(root: @fixture_dir, prefix: "/assets", morph: true) - plug_opts = %{root: @fixture_dir, dev_server: dev_server_opts, morph_dev_server: morph_opts} + push_opts = + Volt.DevServer.init( + root: @fixture_dir, + prefix: "/assets", + morph: [preserve: "[data-owned]"], + document: {Volt.Integration.TestPlug, :render, [@fixture_dir]} + ) + + plug_opts = %{ + root: @fixture_dir, + dev_server: dev_server_opts, + morph_dev_server: morph_opts, + push_dev_server: push_opts + } {:ok, server} = Bandit.start_link( @@ -537,6 +596,58 @@ defmodule Volt.Integration.HMRTest do GenServer.stop(watcher) end + test "the server pushes what it changed to a page it keeps a render of", %{frame: frame} do + content_dir = @fixture_dir <> "-content" + File.rm_rf!(content_dir) + File.mkdir_p!(content_dir) + on_exit(fn -> File.rm_rf!(content_dir) end) + File.write!(Path.join(content_dir, "page.md"), "first|1|en|old description") + + {:ok, _} = Frame.goto(frame.guid, url: base_url("/push/page"), timeout: 10_000) + assert {:ok, "1"} = eval_until(frame, "document.body.dataset.loads", "1") + {:ok, _} = eval_poll(frame, "(window.voltKept = 'kept')") + + # The page requests nothing more: what changed arrives over the websocket. + {:ok, _} = + eval_poll( + frame, + "(window.voltFetches = 0, window.fetch = ((f) => (...a) => (window.voltFetches++, f(...a)))(window.fetch), 0)" + ) + + {:ok, watcher} = + Volt.Watcher.start_link( + root: @fixture_dir, + reload_dirs: [content_dir], + name: String.to_atom("volt_integration_push_#{System.unique_integer([:positive])}") + ) + + Process.sleep(150) + File.write!(Path.join(content_dir, "page.md"), "second|2|ru|new description") + + assert {:ok, "second"} = + eval_until(frame, "document.getElementById('content')?.textContent", "second") + + # The owner of the element re-rendered it from the props the server changed. + assert {:ok, "props:2"} = + eval_until(frame, "document.getElementById('owned')?.textContent", "props:2") + + # Attributes the server changed are applied; one a script set stays. + assert {:ok, "ru"} = eval_poll(frame, "document.documentElement.lang") + assert {:ok, "dark"} = eval_poll(frame, "document.documentElement.dataset.theme") + + assert {:ok, "new description"} = + eval_poll(frame, "document.querySelector('meta[name=description]').content") + + assert {:ok, 1} = + eval_poll(frame, "document.querySelectorAll('meta[name=description]').length") + + assert {:ok, "kept"} = eval_poll(frame, "window.voltKept") + assert {:ok, "1"} = eval_poll(frame, "document.body.dataset.loads") + assert {:ok, 0} = eval_poll(frame, "window.voltFetches") + + GenServer.stop(watcher) + end + test "non-accepted module updates trigger a full reload", %{frame: frame} do write_fixture("full_reload.ts", """ const count = Number(sessionStorage.getItem('voltReloadCount') ?? '0') + 1 From 6c99b70c770f8258c2a1fb34ee817132d8a21dd0 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 4 Oct 2026 13:12:25 +0400 Subject: [PATCH 110/128] Require vize 0.17.1 vize 0.17.1 builds on Vize 0.431.0, which fixes three Vapor compiler bugs. Two can affect components Volt compiles with vapor: true: static attribute values decoded twice in Vapor templates, and default slot content dropped beside a named