From 7539dea15a94834f9939752cc56fa3165029416e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 30 Sep 2026 05:05:57 +0400 Subject: [PATCH 01/14] 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 02/14] 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 03/14] 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 04/14] 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 05/14] 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 06/14] 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 07/14] 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 08/14] 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 09/14] 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 10/14] 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 11/14] 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`